Skip to content

Commit b5126c5

Browse files
committed
Merge branch 'dev'
2 parents 24d0113 + 94197c6 commit b5126c5

File tree

8 files changed

+482
-37
lines changed

8 files changed

+482
-37
lines changed

.travis.yml

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,14 +2,16 @@ language: python
22

33
matrix:
44
include:
5-
- python: 3.6-dev
5+
- python: 3.7-dev
66
env: TOXENV=coveralls
77
- python: 3.4
88
env: TOXENV=py34
99
- python: 3.5-dev
1010
env: TOXENV=py35
1111
- python: 3.6-dev
1212
env: TOXENV=py36
13+
- python: 3.7-dev
14+
env: TOXENV=py37
1315

1416
sudo: required
1517
dist: trusty

README.md

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -125,6 +125,26 @@ It is possible to get a subresourses of an object, and/or specify a query:
125125

126126
Any `kwargs` (here `q=`) is used as a GET parameter for the request.
127127

128+
#### Foreign keys
129+
130+
Foreign keys are handle automatically by the mapper.
131+
132+
```python
133+
>>> site = next(netbox_mapper.get())
134+
>>> print(site.region.name)
135+
"Some region"
136+
```
137+
138+
When accessing to `site.region`, a query will be done to fetch the foreign
139+
object. It will then be saved in cache to avoid unnecessary queries for next
140+
accesses.
141+
142+
To refresh an object and its foreign keys, just do:
143+
144+
```python
145+
>>> site = next(site.get())
146+
```
147+
128148
### POST
129149

130150
Use the `kwargs` of a mapper to send a post request and create a new object:
@@ -134,6 +154,9 @@ Use the `kwargs` of a mapper to send a post request and create a new object:
134154
<NetboxMapper> # corresponding to the new created object
135155
```
136156

157+
If a mapper is sent as parameter, `post()` will automatically take its id.
158+
However, it will not update the foreign object.
159+
137160
### PUT
138161

139162
Use `put()` in a child mapper to update the resource upstream by reflecting
@@ -143,7 +166,7 @@ the changes made in the object attributes:
143166
>>> child_mapper = netbox_mapper.get(1)
144167
>>> child_mapper.name = "another name"
145168
>>> child_mapper.put()
146-
<NetboxMapper> # corresponding to the updated object
169+
<requests> # requests object containing the netbox response
147170
```
148171

149172
### PATCH

netboxapi/exceptions.py

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,7 @@
1-
2-
31
class ForbiddenAsChildError(Exception):
42
pass
3+
4+
5+
class ForbiddenAsPassiveMapperError(Exception):
6+
def __init__(self):
7+
super().__init__("No action is possible for this type of mapper")

netboxapi/mapper.py

Lines changed: 145 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,12 @@
1+
import logging
2+
import re
3+
import requests
14

25
from .api import NetboxAPI
3-
from .exceptions import ForbiddenAsChildError
6+
from .exceptions import ForbiddenAsChildError, ForbiddenAsPassiveMapperError
7+
8+
9+
logger = logging.getLogger("netboxapi")
410

511

612
class NetboxMapper():
@@ -9,6 +15,10 @@ def __init__(self, netbox_api, app_name, model, route=None):
915
self.__app_name__ = app_name
1016
self.__model__ = model
1117
self.__upstream_attrs__ = []
18+
self.__foreign_keys__ = []
19+
20+
#: cache for foreign keys properties
21+
self._fk_cache = {}
1222

1323
self._route = (
1424
route or
@@ -31,6 +41,10 @@ def get(self, *args, **kwargs):
3141
>>> netbox_mapper.get("1", "racks", q="name_to_filter")
3242
3343
Will do a request to "/dcim/sites/1/racks/?q=name_to_filter"
44+
45+
Some specific routes will not return objects with ID (this one for
46+
example: `/ipam/prefixes/{id}/available-prefixes/`). In this case, no
47+
mapper will be built from the result and it will be yield as received.
3448
"""
3549
if args:
3650
route = self._route + "/".join(str(a) for a in args) + "/"
@@ -43,10 +57,15 @@ def get(self, *args, **kwargs):
4357

4458
if isinstance(new_mappers_dict, dict):
4559
new_mappers_dict = [new_mappers_dict]
46-
for d in new_mappers_dict:
47-
yield self._build_new_mapper_from(
48-
d, route + "{}/".format(d["id"])
49-
)
60+
try:
61+
for d in new_mappers_dict:
62+
yield self._build_new_mapper_from(
63+
d, route + "{}/".format(d["id"])
64+
)
65+
except KeyError:
66+
# Result objects have no id, cannot build a mapper from them,
67+
# yield them as received
68+
yield from new_mappers_dict
5069

5170
def post(self, **json):
5271
"""
@@ -65,10 +84,30 @@ def post(self, **json):
6584
6685
:returns: child_mapper: Mapper containing the created object
6786
"""
87+
for k, v in json.items():
88+
if isinstance(v, NetboxMapper):
89+
try:
90+
json[k] = v.id
91+
except AttributeError:
92+
raise ValueError("Mapper {} has no id".format(k))
6893
new_mapper_dict = self.netbox_api.post(self._route, json=json)
69-
route = self._route + "{}/".format(new_mapper_dict["id"])
70-
71-
return self._build_new_mapper_from(new_mapper_dict, route)
94+
try:
95+
return next(self.get(new_mapper_dict["id"]))
96+
except requests.exceptions.HTTPError as e:
97+
if e.response.status_code == 404:
98+
logger.debug(
99+
"Rare case of weird endpoint where object cannot be fetch "
100+
"after POST by using the same endpoint. Returning a "
101+
"mapper based on this answer instead of fetching the "
102+
"entire object."
103+
""
104+
"Do not try to put this mapper as it will fail."
105+
)
106+
return self._build_new_mapper_from(
107+
new_mapper_dict,
108+
self._route + "{}/".format(new_mapper_dict["id"]),
109+
passive_mapper=True
110+
)
72111

73112
def put(self):
74113
"""
@@ -83,17 +122,42 @@ def put(self):
83122
>>> child_mapper = netbox_mapper.get(1)
84123
>>> child_mapper.name = "another name"
85124
>>> child_mapper.put()
86-
<child_mapper>
87125
88-
:returns: child_mapper: Mapper containing the updated object
126+
:returns: request_reponse: requests object containing the netbox
127+
response
89128
"""
90129
assert getattr(self, "id", None) is not None, "self.id does not exist"
91130

92-
new_mapper_dict = self.netbox_api.put(self._route, json=self.to_dict())
93-
return self._build_new_mapper_from(new_mapper_dict, self._route)
131+
return self.netbox_api.put(self._route, json=self.to_dict())
94132

95133
def to_dict(self):
96-
return {a: getattr(self, a, None) for a in self.__upstream_attrs__}
134+
serialize = {}
135+
foreign_keys = self.__foreign_keys__.copy()
136+
for a in self.__upstream_attrs__:
137+
val = getattr(self, a, None)
138+
if isinstance(val, dict):
139+
if "value" in val and "label" in val:
140+
val = val["value"]
141+
elif isinstance(val, NetboxMapper):
142+
foreign_keys.append(a)
143+
continue
144+
145+
serialize[a] = val
146+
147+
for fk in foreign_keys:
148+
attr = getattr(self, fk, None)
149+
if isinstance(attr, int):
150+
serialize[fk] = attr
151+
continue
152+
153+
try:
154+
# check that attr is iterable
155+
iter(attr)
156+
serialize[fk] = [getattr(i, "id", None) for i in attr]
157+
except TypeError:
158+
serialize[fk] = getattr(attr, "id", None)
159+
160+
return serialize
97161

98162
def delete(self, id=None):
99163
"""
@@ -128,12 +192,76 @@ def delete(self, id=None):
128192
delete_route = self._route + "{}/".format(id) if id else self._route
129193
return self.netbox_api.delete(delete_route)
130194

131-
def _build_new_mapper_from(self, mapper_attributes, new_route):
132-
mapper = type(self)(
195+
def _build_new_mapper_from(
196+
self, mapper_attributes, new_route, passive_mapper=False
197+
):
198+
cls = NetboxPassiveMapper if passive_mapper else NetboxMapper
199+
mapper_class = type(
200+
"NetboxMapper_{}_{}".format(
201+
re.sub("_|-", "", self.__model__.title()),
202+
re.sub("_|-", "", "".join(
203+
s.title() for s in new_route.split("/")
204+
))
205+
), (cls,), {}
206+
)
207+
208+
mapper = mapper_class(
133209
self.netbox_api, self.__app_name__, self.__model__, new_route
134210
)
135-
mapper.__upstream_attrs__ = list(mapper_attributes.keys())
211+
mapper.__upstream_attrs__ = []
212+
mapper.__foreign_keys__ = []
136213
for attr, val in mapper_attributes.items():
137-
setattr(mapper, attr, val)
214+
if isinstance(val, dict) and "id" in val:
215+
mapper.__foreign_keys__.append(attr)
216+
mapper._set_property_foreign_key(attr, val)
217+
else:
218+
mapper.__upstream_attrs__.append(attr)
219+
setattr(mapper, attr, val)
138220

139221
return mapper
222+
223+
def _set_property_foreign_key(self, attr, value):
224+
def get_foreign_object(*args):
225+
if hasattr(self, "_{}".format(attr)):
226+
return getattr(self, "_{}".format(attr))
227+
228+
if attr in self._fk_cache:
229+
return self._fk_cache[attr]
230+
231+
url = value["url"]
232+
route = url.replace(self.netbox_api.url, "", 1).lstrip("/")
233+
model, app_name, *params = route.split("/")
234+
235+
fk = list(NetboxMapper(self.netbox_api, model, app_name).get(
236+
*[p for p in params if p]
237+
))
238+
if not fk:
239+
fk = None
240+
elif len(fk) == 1:
241+
fk = fk[0]
242+
243+
self._fk_cache[attr] = fk
244+
return fk
245+
246+
def setter(cls, value):
247+
setattr(self, "_{}".format(attr), value)
248+
249+
try:
250+
self._fk_cache.pop(attr)
251+
except KeyError:
252+
pass
253+
setattr(type(self), attr, property(get_foreign_object, setter))
254+
255+
256+
class NetboxPassiveMapper(NetboxMapper):
257+
def get(self, *args, **kwargs):
258+
raise ForbiddenAsPassiveMapperError()
259+
260+
def post(self, *args, **kwargs):
261+
raise ForbiddenAsPassiveMapperError()
262+
263+
def put(self, *args, **kwargs):
264+
raise ForbiddenAsPassiveMapperError()
265+
266+
def delete(self, *args, **kwargs):
267+
raise ForbiddenAsPassiveMapperError()

setup.cfg

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
[bumpversion]
2-
current_version = 1.0.1
2+
current_version = 1.1.0b1
33
commit = True
44
tag = True
55
tag_name = {new_version}

setup.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@
99

1010
setup(
1111
name="netboxapi",
12-
version="1.0.1",
12+
version="1.1.0",
1313

1414
description="Client API for Netbox",
1515

0 commit comments

Comments
 (0)