tabpy.tabpy_tools.rest.RESTProperty.__set__()   A
last analyzed

Complexity

Conditions 3

Size

Total Lines 5
Code Lines 4

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 4
CRAP Score 3

Importance

Changes 0
Metric Value
eloc 4
dl 0
loc 5
ccs 4
cts 4
cp 1
rs 10
c 0
b 0
f 0
cc 3
nop 3
crap 3
1 1
import abc
2 1
from collections.abc import MutableMapping
3 1
import logging
4 1
import requests
5 1
from requests.auth import HTTPBasicAuth
6 1
from re import compile
7 1
import json as json
8
9
10 1
logger = logging.getLogger(__name__)
11
12
13 1
class ResponseError(Exception):
14
    """Raised when we get an unexpected response."""
15
16 1
    def __init__(self, response):
17 1
        super().__init__("Unexpected server response")
18 1
        self.response = response
19 1
        self.status_code = response.status_code
20
21 1
        try:
22 1
            r = response.json()
23 1
            self.info = r["info"]
24
            self.message = response.json()["message"]
25 1
        except (json.JSONDecodeError, KeyError):
26
            self.info = None
27
            self.message = response.text
28
29 1
    def __str__(self):
30
        return f"({self.status_code}) " f"{self.message} " f"{self.info}"
31
32
33 1
class RequestsNetworkWrapper:
34
    """The NetworkWrapper wraps the underlying network connection to simplify
35
    the interface a bit. This can be replaced with something that can be built
36
    on some other type of network connection, such as PyCURL.
37
38
    This version requires you to instantiate a requests session object to your
39
    liking. It will create a generic session for you if you don't specify it,
40
    which you can modify later.
41
42
    For authentication, use::
43
44
        session.auth = (username, password)
45
    """
46
47 1
    def __init__(self, session=None):
48
        # Set .auth as appropriate.
49 1
        if session is None:
50 1
            session = requests.session()
51
52 1
        self.session = session
53 1
        self.auth = None
54
55 1
    @staticmethod
56 1
    def raise_error(response):
57 1
        logger.error(
58
            f"Error with server response. code={response.status_code}; "
59
            f"text={response.text}"
60
        )
61
62 1
        raise ResponseError(response)
63
64 1
    @staticmethod
65 1
    def _remove_nones(data):
66 1
        if isinstance(data, dict):
67 1
            for k in [k for k, v in data.items() if v is None]:
68 1
                del data[k]
69
70 1
    def _encode_request(self, data):
71 1
        self._remove_nones(data)
72
73 1
        if data is not None:
74 1
            return json.dumps(data)
75
        else:
76
            return None
77
78 1
    def GET(self, url, data, timeout=None):
79
        """Issues a GET request to the URL with the data specified. Returns an
80
        object that is parsed from the response JSON."""
81 1
        self._remove_nones(data)
82
83 1
        logger.info(f"GET {url} with {data}")
84
85 1
        response = self.session.get(url, params=data, timeout=timeout, auth=self.auth)
86 1
        if response.status_code != 200:
87 1
            self.raise_error(response)
88 1
        logger.info(f"response={response.text}")
89
90 1
        if response.text == "":
91
            return dict()
92
        else:
93 1
            return response.json()
94
95 1 View Code Duplication
    def POST(self, url, data, timeout=None):
0 ignored issues
show
Duplication introduced by
This code seems to be duplicated in your project.
Loading history...
96
        """Issues a POST request to the URL with the data specified. Returns an
97
        object that is parsed from the response JSON."""
98 1
        data = self._encode_request(data)
99
100 1
        logger.info(f"POST {url} with {data}")
101 1
        response = self.session.post(
102
            url,
103
            data=data,
104
            headers={"content-type": "application/json"},
105
            timeout=timeout,
106
            auth=self.auth,
107
        )
108
109 1
        if response.status_code not in (200, 201):
110 1
            self.raise_error(response)
111
112 1
        return response.json()
113
114 1 View Code Duplication
    def PUT(self, url, data, timeout=None):
0 ignored issues
show
Duplication introduced by
This code seems to be duplicated in your project.
Loading history...
115
        """Issues a PUT request to the URL with the data specified. Returns an
116
        object that is parsed from the response JSON."""
117 1
        data = self._encode_request(data)
118
119 1
        logger.info(f"PUT {url} with {data}")
120
121 1
        response = self.session.put(
122
            url,
123
            data=data,
124
            headers={"content-type": "application/json"},
125
            timeout=timeout,
126
            auth=self.auth,
127
        )
128 1
        if response.status_code != 200:
129
            self.raise_error(response)
130
131 1
        return response.json()
132
133 1
    def DELETE(self, url, data, timeout=None):
134
        """
135
        Issues a DELETE request to the URL with the data specified. Returns an
136
        object that is parsed from the response JSON.
137
        """
138 1
        if data is not None:
139 1
            data = json.dumps(data)
140
141 1
        logger.info(f"DELETE {url} with {data}")
142
143 1
        response = self.session.delete(url, data=data, timeout=timeout, auth=self.auth)
144
145 1
        if response.status_code <= 499 and response.status_code >= 400:
146
            raise RuntimeError(response.text)
147
148 1
        if response.status_code not in (200, 201, 204):
149
            raise RuntimeError(
150
                f"Error with server response code: {response.status_code}"
151
            )
152
153 1
    def set_credentials(self, username, password):
154
        """
155
        Set credentials for all the TabPy client-server communication
156
        where client is tabpy-tools and server is tabpy-server.
157
158
        Parameters
159
        ----------
160
        username : str
161
            User name (login). Username is case insensitive.
162
163
        password : str
164
            Password in plain text.
165
        """
166 1
        logger.info(f"Setting credentials (username: {username})")
167 1
        self.auth = HTTPBasicAuth(username, password)
168
169
170 1
class ServiceClient:
171
    """
172
    A generic service client.
173
174
    This will take an endpoint URL and a network_wrapper. You can use the
175
    RequestsNetworkWrapper if you want to use the requests module. The
176
    endpoint URL is prepended to all the requests and forwarded to the network
177
    wrapper.
178
    """
179
180 1
    def __init__(self, endpoint, network_wrapper=None):
181 1
        if network_wrapper is None:
182
            network_wrapper = RequestsNetworkWrapper(session=requests.session())
183
184 1
        self.network_wrapper = network_wrapper
185
186 1
        pattern = compile(".*(:[0-9]+)$")
187 1
        if not endpoint.endswith("/") and not pattern.match(endpoint):
188 1
            logger.warning(f"endpoint {endpoint} does not end with '/': appending.")
189 1
            endpoint = endpoint + "/"
190
191 1
        self.endpoint = endpoint
192
193 1
    def GET(self, url, data=None, timeout=None):
194
        """Prepends self.endpoint to the url and issues a GET request."""
195 1
        return self.network_wrapper.GET(self.endpoint + url, data, timeout)
196
197 1
    def POST(self, url, data=None, timeout=None):
198
        """Prepends self.endpoint to the url and issues a POST request."""
199 1
        return self.network_wrapper.POST(self.endpoint + url, data, timeout)
200
201 1
    def PUT(self, url, data=None, timeout=None):
202
        """Prepends self.endpoint to the url and issues a PUT request."""
203 1
        return self.network_wrapper.PUT(self.endpoint + url, data, timeout)
204
205 1
    def DELETE(self, url, data=None, timeout=None):
206
        """Prepends self.endpoint to the url and issues a DELETE request."""
207 1
        self.network_wrapper.DELETE(self.endpoint + url, data, timeout)
208
209 1
    def set_credentials(self, username, password):
210
        """
211
        Set credentials for all the TabPy client-server communication
212
        where client is tabpy-tools and server is tabpy-server.
213
214
        Parameters
215
        ----------
216
        username : str
217
            User name (login). Username is case insensitive.
218
219
        password : str
220
            Password in plain text.
221
        """
222 1
        self.network_wrapper.set_credentials(username, password)
223
224
225 1
class RESTProperty:
226
    """A descriptor that will control the type of value stored."""
227
228 1
    def __init__(self, type, from_json=lambda x: x, to_json=lambda x: x, doc=None):
0 ignored issues
show
Comprehensibility Best Practice introduced by
The variable x does not seem to be defined.
Loading history...
229 1
        self.__doc__ = doc
230 1
        self.type = type
231 1
        self.from_json = from_json
232 1
        self.to_json = to_json
233
234 1
    def __get__(self, instance, _):
235 1
        if instance:
236 1
            try:
237 1
                return getattr(instance, self.name)
238 1
            except AttributeError:
239 1
                raise AttributeError(f"{self.name} has not been set yet.")
240
        else:
241 1
            return self
242
243 1
    def __set__(self, instance, value):
244 1
        if value is not None and not isinstance(value, self.type):
245 1
            value = self.type(value)
246
247 1
        setattr(instance, self.name, value)
248
249 1
    def __delete__(self, instance):
250
        delattr(instance, self.name)
251
252
253 1
class _RESTMetaclass(abc.ABCMeta):
254
    """The metaclass for RESTObjects.
255
256
    This will look into the attributes for the class. If they are a
257
    RESTProperty, then it will add it to the __rest__ set and give it its
258
    name.
259
260
    If the bases have __rest__, then it will add them to the __rest__ set as
261
    well.
262
    """
263
264 1
    def __init__(self, name, bases, dict):
265 1
        super().__init__(name, bases, dict)
266
267 1
        self.__rest__ = set()
268 1
        for base in bases:
269 1
            self.__rest__.update(getattr(base, "__rest__", set()))
270
271 1
        for k, v in dict.items():
272 1
            if isinstance(v, RESTProperty):
273 1
                v.__dict__["name"] = "_" + k
274 1
                self.__rest__.add(k)
275
276
277 1
class RESTObject(MutableMapping, metaclass=_RESTMetaclass):
278
    """A base class that has methods generally useful for interacting with
279
    REST objects. The attributes are accessible either as dict keys or as
280
    attributes. The object also behaves like a dict, even replicating the
281
    repr() functionality.
282
283
    Attributes
284
    ----------
285
286
    __rest__ : set of str
287
        A set of all the rest attribute names. This is generated automatically
288
        and should include all of the base classes' __rest__ as well as any
289
        addition RESTProperty.
290
291
    """
292
293 1
    """ __metaclass__ = _RESTMetaclass"""
294
295 1
    def __init__(self, **kwargs):
296
        """Creates a new instance of the RESTObject.
297
298
        Parameters
299
        ----------
300
301
        The parameters depend on __rest__. Each item in __rest__ is searched
302
        for. If found, it is assigned to the instance. Additional parameters
303
        are ignored.
304
305
        """
306 1
        logger.info(f"Initializing {self.__class__.__name__} from {kwargs}")
307 1
        for attr in self.__rest__:
308 1
            if attr in kwargs:
309 1
                setattr(self, attr, kwargs.pop(attr))
310
311 1
    def __repr__(self):
312
        return (
313
            "{" + ", ".join([repr(k) + ": " + repr(v) for k, v in self.items()]) + "}"
314
        )
315
316 1
    @classmethod
317 1
    def from_json(cls, data):
318
        """Returns a new class object with data populated from json.loads()."""
319 1
        attrs = {}
320 1
        for attr in cls.__rest__:
321 1
            try:
322 1
                value = data[attr]
323 1
            except KeyError:
324 1
                pass
325
            else:
326 1
                prop = cls.__dict__[attr]
327 1
                attrs[attr] = prop.from_json(value)
328 1
        return cls(**attrs)
329
330 1
    def to_json(self):
331
        """Returns a dict representing this object. This dict will be sent to
332
        json.dumps().
333
334
        The keys are the items in __rest__ and the values are the current
335
        values. If missing, it is not included.
336
        """
337 1
        result = {}
338 1
        for attr in self.__rest__:
339 1
            prop = getattr(self.__class__, attr)
340 1
            try:
341 1
                result[attr] = prop.to_json(getattr(self, attr))
342 1
            except AttributeError:
343 1
                pass
344
345 1
        return result
346
347 1
    def __eq__(self, other):
348
        return isinstance(self, type(other)) and all(
349
            (getattr(self, a) == getattr(other, a) for a in self.__rest__)
350
        )
351
352 1
    def __len__(self):
353 1
        return len([a for a in self.__rest__ if hasattr(self, "_" + a)])
354
355 1
    def __iter__(self):
356 1
        return iter([a for a in self.__rest__ if hasattr(self, "_" + a)])
357
358 1
    def __getitem__(self, item):
359 1
        if item not in self.__rest__:
360 1
            raise KeyError(item)
361 1
        try:
362 1
            return getattr(self, item)
363 1
        except AttributeError:
364 1
            raise KeyError(item)
365
366 1
    def __setitem__(self, item, value):
367 1
        if item not in self.__rest__:
368 1
            raise KeyError(item)
369 1
        setattr(self, item, value)
370
371 1
    def __delitem__(self, item):
372
        if item not in self.__rest__:
373
            raise KeyError(item)
374
        try:
375
            delattr(self, "_" + item)
376
        except AttributeError:
377
            raise KeyError(item)
378
379 1
    def __contains__(self, item):
380
        return item in self.__rest__
381
382
383 1
def enum(*values, **kwargs):
384
    """Generates an enum function that only accepts particular values. Other
385
    values will raise a ValueError.
386
387
    Parameters
388
    ----------
389
390
    values : list
391
        These are the acceptable values.
392
393
    type : type
394
        The acceptable types of values. Values will be converted before being
395
        checked against the allowed values. If not specified, no conversion
396
        will be performed.
397
398
    Example
399
    -------
400
401
    >>> my_enum = enum(1, 2, 3, 4, 5, type=int)
402
    >>> a = my_enum(1)
403
    >>> b = my_enum(2)
404
    >>> c = mu_enum(6) # Raises ValueError
405
406
    """
407 1
    if len(values) < 1:
408
        raise ValueError("At least one value is required.")
409 1
    enum_type = kwargs.pop("type", str)
410 1
    if kwargs:
411
        raise TypeError(f'Unexpected parameters: {", ".join(kwargs.keys())}')
412
413 1
    def __new__(cls, value):
414 1
        if value not in cls.values:
415 1
            raise ValueError(
416
                f"{value} is an unexpected value. " f"Expected one of {cls.values}"
417
            )
418
419 1
        return super(enum, cls).__new__(cls, value)
420
421 1
    enum = type("Enum", (enum_type,), {"values": values, "__new__": __new__})
422
423
    return enum
424