Total Complexity | 74 |
Total Lines | 454 |
Duplicated Lines | 8.37 % |
Changes | 0 |
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:
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): |
|
|
|||
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): |
|
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, |
||
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 |