Passed
Push — master ( 227024...1d0698 )
by Oleksandr
02:44
created

tabpy.tabpy_tools.rest   F

Complexity

Total Complexity 74

Size/Duplication

Total Lines 454
Duplicated Lines 8.37 %

Importance

Changes 0
Metric Value
wmc 74
eloc 238
dl 38
loc 454
rs 2.48
c 0
b 0
f 0

33 Methods

Rating   Name   Duplication   Size   Complexity  
A RequestsNetworkWrapper._remove_nones() 0 5 3
A RequestsNetworkWrapper.__init__() 0 7 2
A ResponseError.__str__() 0 2 1
A RequestsNetworkWrapper.raise_error() 0 7 1
A ResponseError.__init__() 0 13 2
A RequestsNetworkWrapper._encode_request() 0 7 2
A RESTObject.__getitem__() 0 7 3
A ServiceClient.POST() 0 3 1
A RESTObject.__init__() 0 16 3
A RESTProperty.__set__() 0 5 3
A RequestsNetworkWrapper.DELETE() 0 22 5
A RESTProperty.__init__() 0 6 3
A RESTObject.from_json() 0 13 4
A RequestsNetworkWrapper.POST() 19 19 2
A _RESTMetaclass.__init__() 0 11 4
A ServiceClient.__init__() 0 14 4
A RESTObject.__repr__() 0 8 1
A RESTProperty.__get__() 0 9 3
A ServiceClient.GET() 0 3 1
A RequestsNetworkWrapper.PUT() 19 19 2
A ServiceClient.set_credentials() 0 14 1
A RESTObject.__iter__() 0 2 1
A RESTObject.__len__() 0 2 1
A RESTObject.to_json() 0 16 3
A RESTObject.__contains__() 0 2 1
A ServiceClient.DELETE() 0 3 1
A ServiceClient.PUT() 0 3 1
A RequestsNetworkWrapper.set_credentials() 0 15 1
A RequestsNetworkWrapper.GET() 0 20 3
A RESTObject.__setitem__() 0 4 2
A RESTObject.__delitem__() 0 7 3
A RESTProperty.__delete__() 0 2 1
A RESTObject.__eq__() 0 5 1

1 Function

Rating   Name   Duplication   Size   Complexity  
A enum() 0 48 4

How to fix   Duplicated Code    Complexity   

Duplicated Code

Duplicate code is one of the most pungent code smells. A rule that is often used is to re-structure code once it is duplicated in three or more places.

Common duplication problems, and corresponding solutions are:

Complexity

 Tip:   Before tackling complexity, make sure that you eliminate any duplication first. This often can reduce the size of classes significantly.

Complex classes like tabpy.tabpy_tools.rest often do a lot of different things. To break such a class down, we need to identify a cohesive component within that class. A common approach to find such a component is to look for fields/methods that share the same prefixes, or suffixes.

Once you have determined the fields that belong together, you can apply the Extract Class refactoring. If the component makes sense as a sub-class, Extract Subclass is also a candidate, and is often faster.

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