]> arthur.barton.de Git - netdata.git/blob - python.d/web_log.chart.py
web_log plugin: add GET dimension from start. Always green now
[netdata.git] / python.d / web_log.chart.py
1 # -*- coding: utf-8 -*-
2 # Description: web log netdata python.d module
3 # Author: l2isbad
4
5 from base import LogService
6 import re
7 import bisect
8 from os import access, R_OK
9 from os.path import getsize
10 from collections import namedtuple
11 from copy import deepcopy
12
13 priority = 60000
14 retries = 60
15
16 ORDER = ['response_statuses', 'response_codes', 'bandwidth', 'response_time', 'requests_per_url', 'http_method',
17          'requests_per_ipproto', 'clients', 'clients_all']
18 CHARTS = {
19     'response_codes': {
20         'options': [None, 'Response Codes', 'requests/s', 'responses', 'web_log.response_codes', 'stacked'],
21         'lines': [
22             ['2xx', '2xx', 'incremental'],
23             ['5xx', '5xx', 'incremental'],
24             ['3xx', '3xx', 'incremental'],
25             ['4xx', '4xx', 'incremental'],
26             ['1xx', '1xx', 'incremental'],
27             ['0xx', 'other', 'incremental'],
28             ['unmatched', 'unmatched', 'incremental']
29         ]},
30     'bandwidth': {
31         'options': [None, 'Bandwidth', 'KB/s', 'bandwidth', 'web_log.bandwidth', 'area'],
32         'lines': [
33             ['resp_length', 'received', 'incremental', 1, 1024],
34             ['bytes_sent', 'sent', 'incremental', -1, 1024]
35         ]},
36     'response_time': {
37         'options': [None, 'Processing Time', 'milliseconds', 'timings', 'web_log.response_time', 'area'],
38         'lines': [
39             ['resp_time_min', 'min', 'incremental', 1, 1000],
40             ['resp_time_max', 'max', 'incremental', 1, 1000],
41             ['resp_time_avg', 'avg', 'incremental', 1, 1000]
42         ]},
43     'clients': {
44         'options': [None, 'Current Poll Unique Client IPs', 'unique ips', 'clients', 'web_log.clients', 'stacked'],
45         'lines': [
46             ['unique_cur_ipv4', 'ipv4', 'incremental', 1, 1],
47             ['unique_cur_ipv6', 'ipv6', 'incremental', 1, 1]
48         ]},
49     'clients_all': {
50         'options': [None, 'All Time Unique Client IPs', 'unique ips', 'clients', 'web_log.clients_all', 'stacked'],
51         'lines': [
52             ['unique_tot_ipv4', 'ipv4', 'absolute', 1, 1],
53             ['unique_tot_ipv6', 'ipv6', 'absolute', 1, 1]
54         ]},
55     'http_method': {
56         'options': [None, 'Requests Per HTTP Method', 'requests/s', 'http methods', 'web_log.http_method', 'stacked'],
57         'lines': [
58             ['GET', 'GET', 'incremental', 1, 1]
59         ]},
60     'requests_per_ipproto': {
61         'options': [None, 'Requests Per IP Protocol', 'requests/s', 'ip protocols', 'web_log.requests_per_ipproto',
62                     'stacked'],
63         'lines': [
64             ['req_ipv4', 'ipv4', 'incremental', 1, 1],
65             ['req_ipv6', 'ipv6', 'incremental', 1, 1]
66         ]},
67     'response_statuses': {
68         'options': [None, 'Response Statuses', 'requests/s', 'responses', 'web_log.response_statuses',
69                     'stacked'],
70         'lines': [
71             ['successful_requests', 'success', 'incremental', 1, 1],
72             ['server_errors', 'error', 'incremental', 1, 1],
73             ['redirects', 'redirect', 'incremental', 1, 1],
74             ['bad_requests', 'bad', 'incremental', 1, 1],
75             ['other_requests', 'other', 'incremental', 1, 1]
76         ]}
77 }
78
79 NAMED_URL_PATTERN = namedtuple('URL_PATTERN', ['description', 'pattern'])
80
81
82 class Service(LogService):
83     def __init__(self, configuration=None, name=None):
84         """
85         :param configuration:
86         :param name:
87         # self._get_data = None  # will be assigned in 'check' method.
88         # self.order = None  # will be assigned in 'create_*_method' method.
89         # self.definitions = None  # will be assigned in 'create_*_method' method.
90         # self.detailed_chart = None  # will be assigned in 'create_*_method' method.
91         # self.http_method_chart = None  # will be assigned in 'create_*_method' method.
92         """
93         LogService.__init__(self, configuration=configuration, name=name)
94         # Variables from module configuration file
95         self.log_path = self.configuration.get('path')
96         self.detailed_response_codes = self.configuration.get('detailed_response_codes', True)
97         self.all_time = self.configuration.get('all_time', True)
98         self.url_pattern = self.configuration.get('categories')  # dict
99         self.custom_log_format = self.configuration.get('custom_log_format')  # dict
100         # Instance variables
101         self.unique_all_time = list()  # sorted list of unique IPs
102         self.regex = None  # will be assigned in 'find_regex' or 'find_regex_custom' method
103         self.resp_time_func = None  # will be assigned in 'find_regex' or 'find_regex_custom' method
104         self.data = {'bytes_sent': 0, 'resp_length': 0, 'resp_time_min': 0, 'resp_time_max': 0,
105                      'resp_time_avg': 0, 'unique_cur_ipv4': 0, 'unique_cur_ipv6': 0, '2xx': 0,
106                      '5xx': 0, '3xx': 0, '4xx': 0, '1xx': 0, '0xx': 0, 'unmatched': 0, 'req_ipv4': 0,
107                      'req_ipv6': 0, 'unique_tot_ipv4': 0, 'unique_tot_ipv6': 0, 'successful_requests': 0,
108                      'redirects': 0, 'bad_requests': 0, 'server_errors': 0, 'other_requests': 0, 'GET': 0}
109
110     def check(self):
111         """
112         :return: bool
113
114         We need to make sure:
115         1. "log_path" is specified in the module configuration file
116         2. "log_path" must be readable by netdata user and must exist
117         3. "log_path' must not be empty. We need at least 1 line to find appropriate pattern to parse
118         4. Plugin can work using predefined patterns (OK for nginx, apache default log format) or user defined
119          pattern. So we need to check if we can parse last line from log file with user pattern OR module patterns.
120         5. All patterns for per_url_request_counter feature are valid regex expressions
121         """
122         if not self.log_path:
123             self.error('log path is not specified')
124             return False
125
126         if not access(self.log_path, R_OK):
127             self.error('%s not readable or not exist' % self.log_path)
128             return False
129
130         if not getsize(self.log_path):
131             self.error('%s is empty' % self.log_path)
132             return False
133
134         # Read last line (or first if there is only one line)
135         with open(self.log_path, 'rb') as logs:
136             logs.seek(-2, 2)
137             while logs.read(1) != b'\n':
138                 logs.seek(-2, 1)
139                 if logs.tell() == 0:
140                     break
141             last_line = logs.readline()
142
143         try:
144             last_line = last_line.decode()
145         except UnicodeDecodeError:
146             try:
147                 last_line = last_line.decode(encoding='utf-8')
148             except (TypeError, UnicodeDecodeError) as error:
149                 self.error(str(error))
150                 return False
151
152         # Custom_log_format or predefined log format.
153         if self.custom_log_format:
154             match_dict, log_name, error = self.find_regex_custom(last_line)
155         else:
156             match_dict, log_name, error = self.find_regex(last_line)
157
158         # "match_dict" is None if there are any problems
159         if match_dict is None:
160             self.error(str(error))
161             return False
162
163         # self.url_pattern check
164         if self.url_pattern:
165             self.url_pattern = check_req_per_url_pattern(self.url_pattern)
166
167         # Double check
168         if not (self.regex and self.resp_time_func):
169             self.error('That can not happen, but it happened. "regex" or "resp_time_func" is None')
170
171         # All is ok. We are about to start.
172         if log_name == 'web_access':
173             self.create_access_charts(match_dict)  # Create charts
174             self._get_data = self._get_access_data
175             self.info('Collected data: %s' % list(match_dict.keys()))
176             return True
177         else:
178             # If it's not access_logs.. Not used at the moment
179             return False
180
181     def find_regex_custom(self, last_line):
182         """
183         :param last_line: str: literally last line from log file
184         :return: tuple where:
185         [0]: dict or None:  match_dict or None
186         [1]: str or None: log_name or None
187         [2]: str: error description
188
189         We are here only if "custom_log_format" is in logs. We need to make sure:
190         1. "custom_log_format" is a dict
191         2. "pattern" in "custom_log_format" and pattern is <str> instance
192         3. if "time_multiplier" is in "custom_log_format" it must be <int> instance
193
194         If all parameters is ok we need to make sure:
195         1. Pattern search is success
196         2. Pattern search contains named subgroups (?P<subgroup_name>) (= "match_dict")
197
198         If pattern search is success we need to make sure:
199         1. All mandatory keys ['address', 'code', 'bytes_sent', 'method', 'url'] are in "match_dict"
200
201         If this is True we need to make sure:
202         1. All mandatory key values from "match_dict" have the correct format
203          ("code" is integer, "method" is uppercase word, etc)
204
205         If non mandatory keys in "match_dict" we need to make sure:
206         1. All non mandatory key values from match_dict ['resp_length', 'resp_time'] have the correct format
207          ("resp_length" is integer or "-", "resp_time" is integer or float)
208
209         """
210         if not is_dict(self.custom_log_format):
211             return find_regex_return(msg='Custom log: "custom_log_format" is not a <dict>')
212
213         pattern = self.custom_log_format.get('pattern')
214         if not (pattern and isinstance(pattern, str)):
215             return find_regex_return(msg='Custom log: "pattern" option is not specified or type is not <str>')
216
217         resp_time_func = self.custom_log_format.get('time_multiplier') or 0
218
219         if not isinstance(resp_time_func, int):
220             return find_regex_return(msg='Custom log: "time_multiplier" is not an integer')
221
222         try:
223             regex = re.compile(pattern)
224         except re.error as error:
225             return find_regex_return(msg='Pattern compile error: %s' % str(error))
226
227         match = regex.search(last_line)
228         if match:
229             match_dict = match.groupdict() or None
230         else:
231             return find_regex_return(msg='Custom log: pattern search FAILED')
232
233         if match_dict is None:
234             find_regex_return(msg='Custom log: search OK but contains no named subgroups'
235                                   ' (you need to use ?P<subgroup_name>)')
236         else:
237             mandatory_dict = {'address': r'[\da-f.:]+',
238                               'code': r'[1-9]\d{2}',
239                               'method': r'[A-Z]+',
240                               'bytes_sent': r'\d+|-'}
241             optional_dict = {'resp_length': r'\d+',
242                              'resp_time': r'[\d.]+'}
243
244             mandatory_values = set(mandatory_dict) - set(match_dict)
245             if mandatory_values:
246                 return find_regex_return(msg='Custom log: search OK but some mandatory keys (%s) are missing'
247                                          % list(mandatory_values))
248             else:
249                 for key in mandatory_dict:
250                     if not re.search(mandatory_dict[key], match_dict[key]):
251                         return find_regex_return(msg='Custom log: can\'t parse "%s": %s'
252                                                      % (key, match_dict[key]))
253
254             optional_values = set(optional_dict) & set(match_dict)
255             for key in optional_values:
256                 if not re.search(optional_dict[key], match_dict[key]):
257                     return find_regex_return(msg='Custom log: can\'t parse "%s": %s'
258                                                  % (key, match_dict[key]))
259
260             dot_in_time = '.' in match_dict.get('resp_time', '')
261             if dot_in_time:
262                 self.resp_time_func = lambda time: time * (resp_time_func or 1000000)
263             else:
264                 self.resp_time_func = lambda time: time * (resp_time_func or 1)
265
266             self.regex = regex
267             return find_regex_return(match_dict=match_dict,
268                                      log_name='web_access')
269
270     def find_regex(self, last_line):
271         """
272         :param last_line: str: literally last line from log file
273         :return: tuple where:
274         [0]: dict or None:  match_dict or None
275         [1]: str or None: log_name or None
276         [2]: str: error description
277         We need to find appropriate pattern for current log file
278         All logic is do a regex search through the string for all predefined patterns
279         until we find something or fail.
280         """
281         # REGEX: 1.IPv4 address 2.HTTP method 3. URL 4. Response code
282         # 5. Bytes sent 6. Response length 7. Response process time
283         acs_default = re.compile(r'(?P<address>[\da-f.:]+)'
284                                  r' -.*?"(?P<method>[A-Z]+)'
285                                  r' (?P<url>.*?)"'
286                                  r' (?P<code>[1-9]\d{2})'
287                                  r' (?P<bytes_sent>\d+|-)')
288
289         acs_apache_ext_insert = re.compile(r'(?P<address>[\da-f.:]+)'
290                                            r' -.*?"(?P<method>[A-Z]+)'
291                                            r' (?P<url>.*?)"'
292                                            r' (?P<code>[1-9]\d{2})'
293                                            r' (?P<bytes_sent>\d+|-)'
294                                            r' (?P<resp_length>\d+)'
295                                            r' (?P<resp_time>\d+) ')
296
297         acs_apache_ext_append = re.compile(r'(?P<address>[\da-f.:]+)'
298                                            r' -.*?"(?P<method>[A-Z]+)'
299                                            r' (?P<url>.*?)"'
300                                            r' (?P<code>[1-9]\d{2})'
301                                            r' (?P<bytes_sent>\d+|-)'
302                                            r' .*?'
303                                            r' (?P<resp_length>\d+)'
304                                            r' (?P<resp_time>\d+)'
305                                            r'(?: |$)')
306
307         acs_nginx_ext_insert = re.compile(r'(?P<address>[\da-f.:]+)'
308                                           r' -.*?"(?P<method>[A-Z]+)'
309                                           r' (?P<url>.*?)"'
310                                           r' (?P<code>[1-9]\d{2})'
311                                           r' (?P<bytes_sent>\d+)'
312                                           r' (?P<resp_length>\d+)'
313                                           r' (?P<resp_time>\d\.\d+) ')
314
315         acs_nginx_ext_append = re.compile(r'(?P<address>[\da-f.:]+)'
316                                           r' -.*?"(?P<method>[A-Z]+)'
317                                           r' (?P<url>.*?)"'
318                                           r' (?P<code>[1-9]\d{2})'
319                                           r' (?P<bytes_sent>\d+)'
320                                           r' .*?'
321                                           r' (?P<resp_length>\d+)'
322                                           r' (?P<resp_time>\d\.\d+)')
323
324         def func_usec(time):
325             return time
326
327         def func_sec(time):
328             return time * 1000000
329
330         r_regex = [acs_apache_ext_insert, acs_apache_ext_append, acs_nginx_ext_insert,
331                    acs_nginx_ext_append, acs_default]
332         r_function = [func_usec, func_usec, func_sec, func_sec, func_usec]
333         regex_function = zip(r_regex, r_function)
334
335         match_dict = dict()
336         for regex, function in regex_function:
337             match = regex.search(last_line)
338             if match:
339                 self.regex = regex
340                 self.resp_time_func = function
341                 match_dict = match.groupdict()
342                 break
343
344         return find_regex_return(match_dict=match_dict or None,
345                                  log_name='web_access',
346                                  msg='Unknown log format. You need to use "custom_log_format" feature.')
347
348     def create_access_charts(self, match_dict):
349         """
350         :param match_dict: dict: regex.search.groupdict(). Ex. {'address': '127.0.0.1', 'code': '200', 'method': 'GET'}
351         :return:
352         Create additional charts depending on the 'match_dict' keys and configuration file options
353         1. 'time_response' chart is removed if there is no 'resp_time' in match_dict.
354         2. Other stuff is just remove/add chart depending on yes/no in conf
355         """
356         def find_job_name(override_name, name):
357             """
358             :param override_name: str: 'name' var from configuration file
359             :param name: str: 'job_name' from configuration file
360             :return: str: new job name
361             We need this for dynamic charts. Actually same logic as in python.d.plugin.
362             """
363             add_to_name = override_name or name
364             if add_to_name:
365                 return '_'.join(['web_log', re.sub('\s+', '_', add_to_name)])
366             else:
367                 return 'web_log'
368
369         self.order = ORDER[:]
370         self.definitions = deepcopy(CHARTS)
371
372         job_name = find_job_name(self.override_name, self.name)
373         self.detailed_chart = 'CHART %s.detailed_response_codes ""' \
374                               ' "Detailed Response Codes" requests/s responses' \
375                               ' web_log.detailed_response_codes stacked 1 %s\n' % (job_name, self.update_every)
376         self.http_method_chart = 'CHART %s.http_method' \
377                                  ' "" "Requests Per HTTP Method" requests/s "http methods"' \
378                                  ' web_log.http_method stacked 2 %s\n' \
379                                  'DIMENSION GET GET incremental\n' % (job_name, self.update_every)
380
381         # Remove 'request_time' chart from ORDER if resp_time not in match_dict
382         if 'resp_time' not in match_dict:
383             self.order.remove('response_time')
384         # Remove 'clients_all' chart from ORDER if specified in the configuration
385         if not self.all_time:
386             self.order.remove('clients_all')
387         # Add 'detailed_response_codes' chart if specified in the configuration
388         if self.detailed_response_codes:
389             self.order.append('detailed_response_codes')
390             self.definitions['detailed_response_codes'] = {'options': [None, 'Detailed Response Codes', 'requests/s',
391                                                                        'responses', 'web_log.detailed_response_codes',
392                                                                        'stacked'],
393                                                            'lines': []}
394
395         # Add 'requests_per_url' chart if specified in the configuration
396         if self.url_pattern:
397             self.definitions['requests_per_url'] = {'options': [None, 'Requests Per Url', 'requests/s',
398                                                                 'urls', 'web_log.requests_per_url', 'stacked'],
399                                                     'lines': [['pur_other', 'other', 'incremental']]}
400             for elem in self.url_pattern:
401                 self.definitions['requests_per_url']['lines'].append([elem.description, elem.description[4:],
402                                                                       'incremental'])
403                 self.data.update({elem.description: 0})
404             self.data.update({'pur_other': 0})
405         else:
406             self.order.remove('requests_per_url')
407
408     def add_new_dimension(self, dimension, line_list, chart_string, key):
409         """
410         :param dimension: str: response status code. Ex.: '202', '499'
411         :param line_list: list: Ex.: ['202', '202', 'incremental']
412         :param chart_string: Current string we need to pass to netdata to rebuild the chart
413         :param key: str: CHARTS dict key (chart name). Ex.: 'response_time'
414         :return: str: new chart string = previous + new dimensions
415         """
416         self.data.update({dimension: 0})
417         # SET method check if dim in _dimensions
418         self._dimensions.append(dimension)
419         # UPDATE method do SET only if dim in definitions
420         self.definitions[key]['lines'].append(line_list)
421         chart = chart_string
422         chart += "%s %s\n" % ('DIMENSION', ' '.join(line_list))
423         print(chart)
424         return chart
425
426     def _get_access_data(self):
427         """
428         Parse new log lines
429         :return: dict OR None
430         None if _get_raw_data method fails.
431         In all other cases - dict.
432         """
433         raw = self._get_raw_data()
434         if raw is None:
435             return None
436
437         request_time, unique_current = list(), list()
438         request_counter = {'count': 0, 'sum': 0}
439         ip_address_counter = {'unique_cur_ip': 0}
440         for line in raw:
441             match = self.regex.search(line)
442             if match:
443                 match_dict = match.groupdict()
444                 try:
445                     code = ''.join([match_dict['code'][0], 'xx'])
446                     self.data[code] += 1
447                 except KeyError:
448                     self.data['0xx'] += 1
449                 # detailed response code
450                 if self.detailed_response_codes:
451                     self._get_data_detailed_response_codes(match_dict['code'])
452                 # response statuses
453                 self._get_data_statuses(match_dict['code'])
454                 # requests per url
455                 if self.url_pattern:
456                     self._get_data_per_url(match_dict['url'])
457                 # requests per http method
458                 self._get_data_http_method(match_dict['method'])
459                 # bandwidth sent
460                 bytes_sent = match_dict['bytes_sent'] if '-' not in match_dict['bytes_sent'] else 0
461                 self.data['bytes_sent'] += int(bytes_sent)
462                 # request processing time and bandwidth received
463                 if 'resp_length' in match_dict:
464                     self.data['resp_length'] += int(match_dict['resp_length'])
465                 if 'resp_time' in match_dict:
466                     resp_time = self.resp_time_func(float(match_dict['resp_time']))
467                     bisect.insort_left(request_time, resp_time)
468                     request_counter['count'] += 1
469                     request_counter['sum'] += resp_time
470                 # requests per ip proto
471                 proto = 'ipv4' if '.' in match_dict['address'] else 'ipv6'
472                 self.data['req_' + proto] += 1
473                 # unique clients ips
474                 if address_not_in_pool(self.unique_all_time, match_dict['address'],
475                                        self.data['unique_tot_ipv4'] + self.data['unique_tot_ipv6']):
476                         self.data['unique_tot_' + proto] += 1
477                 if address_not_in_pool(unique_current, match_dict['address'], ip_address_counter['unique_cur_ip']):
478                         self.data['unique_cur_' + proto] += 1
479                         ip_address_counter['unique_cur_ip'] += 1
480             else:
481                 self.data['unmatched'] += 1
482
483         # timings
484         if request_time:
485             self.data['resp_time_min'] += int(request_time[0])
486             self.data['resp_time_avg'] += int(round(float(request_counter['sum']) / request_counter['count']))
487             self.data['resp_time_max'] += int(request_time[-1])
488         return self.data
489
490     def _get_data_detailed_response_codes(self, code):
491         """
492         :param code: str: CODE from parsed line. Ex.: '202, '499'
493         :return:
494         Calls add_new_dimension method If the value is found for the first time
495         """
496         if code not in self.data:
497             chart_string_copy = self.detailed_chart
498             self.detailed_chart = self.add_new_dimension(code, [code, code, 'incremental'],
499                                                          chart_string_copy, 'detailed_response_codes')
500         self.data[code] += 1
501
502     def _get_data_http_method(self, method):
503         """
504         :param method: str: METHOD from parsed line. Ex.: 'GET', 'POST'
505         :return:
506         Calls add_new_dimension method If the value is found for the first time
507         """
508         if method not in self.data:
509             chart_string_copy = self.http_method_chart
510             self.http_method_chart = self.add_new_dimension(method, [method, method, 'incremental'],
511                                                             chart_string_copy, 'http_method')
512         self.data[method] += 1
513
514     def _get_data_per_url(self, url):
515         """
516         :param url: str: URL from parsed line
517         :return:
518         Scan through string looking for the first location where patterns produce a match for all user
519         defined patterns
520         """
521         match = None
522         for elem in self.url_pattern:
523             if elem.pattern.search(url):
524                 self.data[elem.description] += 1
525                 match = True
526                 break
527         if not match:
528             self.data['pur_other'] += 1
529
530     def _get_data_statuses(self, code):
531         """
532         :param code: str: response status code. Ex.: '202', '499'
533         :return:
534         """
535         code_class = code[0]
536         if code_class == '2' or code == '304' or code_class == '1':
537             self.data['successful_requests'] += 1
538         elif code_class == '3':
539             self.data['redirects'] += 1
540         elif code_class == '4':
541             self.data['bad_requests'] += 1
542         elif code_class == '5':
543             self.data['server_errors'] += 1
544         else:
545             self.data['other_requests'] += 1
546
547
548 def address_not_in_pool(pool, address, pool_size):
549     """
550     :param pool: list of ip addresses
551     :param address: ip address
552     :param pool_size: current pool size
553     :return: True if address not in pool. False if address in pool.
554     """
555     index = bisect.bisect_left(pool, address)
556     if index < pool_size:
557         if pool[index] == address:
558             return False
559         else:
560             bisect.insort_left(pool, address)
561             return True
562     else:
563         bisect.insort_left(pool, address)
564         return True
565
566
567 def find_regex_return(match_dict=None, log_name=None, msg='Generic error message'):
568     """
569     :param match_dict: dict: re.search.groupdict() or None
570     :param log_name: str: log name
571     :param msg: str: error description
572     :return: tuple:
573     """
574     return match_dict, log_name, msg
575
576
577 def check_req_per_url_pattern(url_pattern):
578     """
579     :param url_pattern: dict: ex. {'dim1': 'pattern1>', 'dim2': '<pattern2>'}
580     :return: list of named tuples or None:
581      We need to make sure all patterns are valid regular expressions
582     """
583     if not is_dict(url_pattern):
584         return None
585
586     result = list()
587
588     def is_valid_pattern(pattern):
589         """
590         :param pattern: str
591         :return: re.compile(pattern) or None
592         """
593         if not isinstance(pattern, str):
594             return False
595         else:
596             try:
597                 compile_pattern = re.compile(pattern)
598             except re.error:
599                 return False
600             else:
601                 return compile_pattern
602
603     for dimension, regex in url_pattern.items():
604         valid_pattern = is_valid_pattern(regex)
605         if isinstance(dimension, str) and valid_pattern:
606             result.append(NAMED_URL_PATTERN(description='_'.join(['pur', dimension]), pattern=valid_pattern))
607
608     return result or None
609
610
611 def is_dict(obj):
612     """
613     :param obj: dict:
614     :return: True or False
615     obj can be <dict> or <OrderedDict>
616     """
617     try:
618         obj.keys()
619     except AttributeError:
620         return False
621     else:
622         return True