celus-nigiri 1.3.3__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,19 @@
1
+ Copyright 2022 Big Dig Data
2
+
3
+ Permission is hereby granted, free of charge, to any person obtaining a copy
4
+ of this software and associated documentation files (the "Software"), to deal
5
+ in the Software without restriction, including without limitation the rights
6
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
7
+ copies of the Software, and to permit persons to whom the Software is
8
+ furnished to do so, subject to the following conditions:
9
+
10
+ The above copyright notice and this permission notice shall be included in all
11
+ copies or substantial portions of the Software.
12
+
13
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
14
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
15
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
16
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
17
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
18
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
19
+ SOFTWARE.
@@ -0,0 +1,21 @@
1
+ Metadata-Version: 2.1
2
+ Name: celus-nigiri
3
+ Version: 1.3.3
4
+ Summary: Library for downloading and parsing counter-like data.
5
+ License: MIT
6
+ Author: Beda Kosata
7
+ Author-email: beda@bigdigdata.com
8
+ Requires-Python: >=3.8.9,<4.0.0
9
+ Classifier: License :: OSI Approved :: MIT License
10
+ Classifier: Programming Language :: Python :: 3
11
+ Classifier: Programming Language :: Python :: 3.9
12
+ Classifier: Programming Language :: Python :: 3.10
13
+ Classifier: Programming Language :: Python :: 3.11
14
+ Classifier: Programming Language :: Python :: 3.12
15
+ Classifier: Topic :: Software Development :: Libraries
16
+ Requires-Dist: celus-pycounter (>=4.0.1,<4.1.0)
17
+ Requires-Dist: chardet (>=5.0.0,<6.0.0)
18
+ Requires-Dist: dateparser (>=1.1.1,<1.2.0)
19
+ Requires-Dist: ijson (>=3.2.3,<3.3.0)
20
+ Requires-Dist: python-decouple (>=3.6,<4.0)
21
+ Requires-Dist: xmltodict (>=0.13.0,<0.14.0)
@@ -0,0 +1,3 @@
1
+ from .record import CounterRecord
2
+
3
+ __all__ = ['CounterRecord']
@@ -0,0 +1,80 @@
1
+ """ Celus format for non-counter data """
2
+ import typing
3
+ import warnings
4
+ from datetime import date
5
+ from functools import lru_cache
6
+
7
+ from celus_nigiri import CounterRecord
8
+ from celus_nigiri.utils import parse_date_fuzzy
9
+
10
+ DEFAULT_COLUMN_MAP = {
11
+ 'Metric': 'metric',
12
+ 'Organization': 'organization',
13
+ 'Source': 'title',
14
+ 'Title': 'title',
15
+ }
16
+
17
+
18
+ @lru_cache
19
+ def col_name_to_month(row_name: str) -> typing.Optional[date]:
20
+ """
21
+ >>> col_name_to_month('Jan 2019')
22
+ datetime.date(2019, 1, 1)
23
+ >>> col_name_to_month('2019-02')
24
+ datetime.date(2019, 2, 1)
25
+ >>> col_name_to_month('prase') is None
26
+ True
27
+ """
28
+ _date = parse_date_fuzzy(row_name)
29
+ if _date:
30
+ return _date.replace(day=1)
31
+ return None
32
+
33
+
34
+ def custom_data_to_records(
35
+ records: typing.Generator[dict, None, None], column_map=None, extra_dims=None, initial_data=None
36
+ ) -> typing.Generator[CounterRecord, None, None]:
37
+ warnings.warn(
38
+ "This function is deprecated please use celus-nibbler to parse celus format",
39
+ DeprecationWarning,
40
+ )
41
+
42
+ # prepare the keyword arguments
43
+ if initial_data is None:
44
+ initial_data = {}
45
+ if column_map is None:
46
+ column_map = DEFAULT_COLUMN_MAP
47
+ if extra_dims is None:
48
+ extra_dims = []
49
+ # process the records
50
+ result = []
51
+ for record in records:
52
+ implicit_dimensions = {}
53
+ explicit_dimensions = {}
54
+ monthly_values = {}
55
+ for key, value in record.items():
56
+ month = col_name_to_month(key)
57
+ if month:
58
+ monthly_values[month] = int(value)
59
+ else:
60
+ if key in column_map:
61
+ implicit_dimensions[column_map[key]] = value
62
+ elif key in extra_dims:
63
+ explicit_dimensions[key] = value
64
+ else:
65
+ raise KeyError(f'We don\'t know how to interpret the column "{key}"')
66
+ # we put initial data into the data we read - these are usually dimensions that are fixed
67
+ # for the whole import and are not part of the data itself
68
+ for key, value in initial_data.items():
69
+ if key not in implicit_dimensions:
70
+ implicit_dimensions[key] = value # only update if the value is not present
71
+ for month, value in monthly_values.items():
72
+ result.append(
73
+ CounterRecord(
74
+ value=value,
75
+ start=month,
76
+ dimension_data=explicit_dimensions,
77
+ **implicit_dimensions,
78
+ )
79
+ )
80
+ return (e for e in result) # TODO convert this into a propper generator
@@ -0,0 +1,504 @@
1
+ import csv
2
+ import json
3
+ import logging
4
+ import traceback
5
+ import typing
6
+ import urllib
7
+ from io import BytesIO, StringIO
8
+ from urllib.parse import urlparse
9
+
10
+ import requests
11
+ import xmltodict
12
+ from celus_pycounter import report, sushi
13
+ from decouple import Csv, config
14
+ from requests import Response
15
+
16
+ from .counter5 import (
17
+ Counter5DRReport,
18
+ Counter5IRM1Report,
19
+ Counter5IRReport,
20
+ Counter5PRReport,
21
+ Counter5ReportBase,
22
+ Counter5TRReport,
23
+ CounterError,
24
+ )
25
+ from .error_codes import error_code_to_severity
26
+ from .exceptions import SushiException
27
+
28
+ logger = logging.getLogger(__name__)
29
+
30
+
31
+ class SushiError:
32
+ def __init__(self, code='', text='', full_log='', raw_data=None, severity=None, data=None):
33
+ self.code = code
34
+ self.severity = severity if isinstance(severity, str) else error_code_to_severity(code)
35
+ self.text = text
36
+ self.full_log = full_log
37
+ self.data = data
38
+ self.raw_data = raw_data
39
+
40
+ def __str__(self):
41
+ return self.full_log
42
+
43
+ @property
44
+ def is_warning(self):
45
+ if self.severity:
46
+ return self.severity.lower() == "warning"
47
+
48
+ return False
49
+
50
+ @property
51
+ def is_info(self):
52
+ if self.severity:
53
+ return self.severity.lower() == "info"
54
+
55
+ return False
56
+
57
+
58
+ def convert_key(key: str) -> str:
59
+ # remove namespace
60
+ key = key.split(":", 1)[-1]
61
+
62
+ # to lower
63
+ key = key.lower()
64
+
65
+ return key
66
+
67
+
68
+ def recursive_finder(
69
+ data: typing.Any, names: typing.List[str]
70
+ ) -> typing.Generator[typing.Any, None, None]:
71
+ lower_names = [e.lower() for e in names]
72
+
73
+ if isinstance(data, list):
74
+ for e in data:
75
+ for found in recursive_finder(e, names):
76
+ yield found
77
+ elif isinstance(data, dict):
78
+ for key, data in data.items():
79
+ if convert_key(key) in lower_names:
80
+ yield data
81
+
82
+ if isinstance(data, (list, dict)):
83
+ for found in recursive_finder(data, names):
84
+ yield found
85
+
86
+
87
+ class SushiClientBase:
88
+ def __init__(self, url, requestor_id, customer_id=None, extra_params=None, auth=None):
89
+ self.url = url
90
+ self.requestor_id = requestor_id
91
+ self.customer_id = customer_id
92
+ self.extra_params = extra_params
93
+ self.auth = auth
94
+
95
+ @classmethod
96
+ def extract_errors_from_data(cls, report_data) -> [SushiError]:
97
+ raise NotImplementedError()
98
+
99
+ def report_to_string(self, report_data):
100
+ raise NotImplementedError()
101
+
102
+ def get_report_data(
103
+ self,
104
+ report_type,
105
+ begin_date,
106
+ end_date,
107
+ output_content: typing.Optional[typing.IO] = None,
108
+ params=None,
109
+ ):
110
+ raise NotImplementedError()
111
+
112
+
113
+ class Sushi5Client(SushiClientBase):
114
+
115
+ """
116
+ Client for SUSHI and COUNTER 5 protocol
117
+ """
118
+
119
+ CUSTOMER_ID_PARAM = 'customer_id'
120
+ REQUESTOR_ID_PARAM = 'requestor_id'
121
+
122
+ report_types = {
123
+ 'tr': {
124
+ 'name': 'Title report',
125
+ 'subreports': {
126
+ 'b1': 'Book requests excluding OA_Gold',
127
+ 'b2': 'Books - access denied',
128
+ 'b3': 'Book Usage by Access Type',
129
+ 'j1': 'Journal requests excluding OA_Gold',
130
+ 'j2': 'Journal articles - access denied',
131
+ 'j3': 'Journal usage by Access Type',
132
+ 'j4': 'Journal Requests by YOP (Excluding OA_Gold)',
133
+ },
134
+ },
135
+ 'dr': {
136
+ 'name': 'Database report',
137
+ 'subreports': {'d1': 'Search and Item usage', 'd2': 'Database Access Denied'},
138
+ },
139
+ 'ir': {
140
+ 'name': 'Item report',
141
+ 'subreports': {'a1': 'Journal article requests', 'm1': 'Multimedia item requests'},
142
+ },
143
+ 'pr': {'name': 'Platform report', 'subreports': {'p1': 'View by Metric_Type'}},
144
+ }
145
+
146
+ # sets of additional parameters for specific setups
147
+ EXTRA_PARAMS = {
148
+ # split data in TR report to most possible dimensions for most granular data
149
+ 'maximum_split': {
150
+ 'tr': {'attributes_to_show': 'YOP|Access_Method|Access_Type|Data_Type|Section_Type'},
151
+ 'ir': {'attributes_to_show': 'YOP|Access_Method|Access_Type|Data_Type'},
152
+ 'pr': {'attributes_to_show': 'Access_Method|Data_Type'},
153
+ 'dr': {'attributes_to_show': 'Access_Method|Data_Type'},
154
+ }
155
+ }
156
+
157
+ def __init__(self, url, requestor_id, customer_id=None, extra_params=None, auth=None):
158
+ super().__init__(url, requestor_id, customer_id, extra_params, auth)
159
+ self.session = requests.Session()
160
+ self.session.headers.update(
161
+ {
162
+ 'User-Agent': 'Mozilla/5.0 (X11; Ubuntu; Linux x86_64; rv:68.0) Gecko/20100101 Firefox/68.0'
163
+ }
164
+ )
165
+ proxy = self._get_proxy(url)
166
+ if proxy:
167
+ logger.debug('Using proxy %s for server %s', proxy['proxy'], url)
168
+ proxy_rec = 'http://{username}:{password}@{proxy}:{port}/'.format(**proxy)
169
+ self.session.proxies.update({'http': proxy_rec, 'https': proxy_rec})
170
+
171
+ def _get_proxy(self, url):
172
+ parsed = urlparse(url)
173
+ proxy_settings = config(
174
+ 'SUSHI_PROXIES', cast=Csv(cast=Csv(post_process=tuple), delimiter=';'), default=''
175
+ )
176
+ for rec in proxy_settings:
177
+ if len(rec) != 5:
178
+ logger.warning('Incorrect proxy definition: [%s]', rec)
179
+ continue
180
+ if not rec[2].isdigit():
181
+ logger.warning('Incorrect proxy port: [%s]', rec[2])
182
+ continue
183
+ if rec[0] == parsed.netloc:
184
+ return {
185
+ 'proxy': rec[1],
186
+ 'port': int(rec[2]),
187
+ 'username': rec[3],
188
+ 'password': rec[4],
189
+ }
190
+
191
+ @classmethod
192
+ def _encode_date(cls, value) -> str:
193
+ """
194
+ >>> Sushi5Client._encode_date('2018-02-30')
195
+ '2018-02'
196
+ >>> Sushi5Client._encode_date(datetime(2018, 7, 6, 12, 25, 30))
197
+ '2018-07'
198
+ """
199
+ if hasattr(value, 'isoformat'):
200
+ return value.isoformat()[:7]
201
+ return value[:7]
202
+
203
+ def _construct_url_params(self, extra=None):
204
+ result = {
205
+ self.CUSTOMER_ID_PARAM: self.customer_id if self.customer_id else self.requestor_id
206
+ }
207
+ if self.requestor_id:
208
+ result[self.REQUESTOR_ID_PARAM] = self.requestor_id
209
+ if self.extra_params:
210
+ result.update(self.extra_params)
211
+ if extra:
212
+ result.update(extra)
213
+ return result
214
+
215
+ def _make_request(self, url, params, stream=False):
216
+ logger.debug('Making request to :%s?%s', url, urllib.parse.urlencode(params))
217
+ kwargs = {}
218
+ if self.auth:
219
+ kwargs['auth'] = self.auth
220
+ return self.session.get(url, params=params, stream=stream, **kwargs)
221
+
222
+ def get_available_reports_raw(self, params=None) -> bytes:
223
+ """
224
+ Return a list of available reports
225
+ :return:
226
+ """
227
+ url = '/'.join([self.url.rstrip('/'), 'reports/'])
228
+ params = self._construct_url_params(extra=params)
229
+ response = self._make_request(url, params)
230
+ response.raise_for_status()
231
+ return response.content
232
+
233
+ def get_available_reports(self, params=None) -> typing.Generator[dict, None, None]:
234
+ content = self.get_available_reports_raw(params=params)
235
+ reports = self.report_to_data(content)
236
+ return reports
237
+
238
+ def fetch_report_data(
239
+ self,
240
+ report_type,
241
+ begin_date,
242
+ end_date,
243
+ dump_file: typing.Optional[typing.IO] = None,
244
+ params=None,
245
+ ) -> Response:
246
+ """
247
+ Makes a request for the data, stores the resulting data into `dump_file` and returns
248
+ the response object for further inspection
249
+ :param report_type:
250
+ :param begin_date:
251
+ :param end_date:
252
+ :param dump_file: where to put file output
253
+ :param params:
254
+ :return:
255
+ """
256
+ report_type = self._check_report_type(report_type)
257
+ url = '/'.join([self.url.rstrip('/'), 'reports', report_type])
258
+ params = self._construct_url_params(extra=params)
259
+ params['begin_date'] = self._encode_date(begin_date)
260
+ params['end_date'] = self._encode_date(end_date)
261
+ response = self._make_request(url, params, stream=bool(dump_file))
262
+ if dump_file is not None:
263
+ for data in response.iter_content(1024 * 1024):
264
+ dump_file.write(data)
265
+ dump_file.seek(0)
266
+ return response
267
+
268
+ def get_report_data(
269
+ self,
270
+ report_type,
271
+ begin_date,
272
+ end_date,
273
+ output_content: typing.Optional[typing.IO] = None,
274
+ params=None,
275
+ ) -> Counter5ReportBase:
276
+ response = self.fetch_report_data(
277
+ report_type, begin_date, end_date, params=params, dump_file=output_content
278
+ )
279
+ if 200 <= response.status_code < 300 or 400 <= response.status_code < 600:
280
+ # status codes in the 4xx range may be OK and just provide additional signal
281
+ # about an issue - we need to parse the result in case there is more info
282
+ # in the body
283
+ report_class: typing.Type[Counter5ReportBase]
284
+ report_id = report_type.lower()
285
+ if report_id == 'tr':
286
+ report_class = Counter5TRReport
287
+ elif report_id == 'dr':
288
+ report_class = Counter5DRReport
289
+ elif report_id == 'pr':
290
+ report_class = Counter5PRReport
291
+ elif report_id == 'ir':
292
+ report_class = Counter5IRReport
293
+ elif report_id == 'ir_m1':
294
+ report_class = Counter5IRM1Report
295
+ else:
296
+ raise NotImplementedError()
297
+
298
+ if output_content:
299
+ output_content.seek(0)
300
+ return report_class(output_content, http_status_code=response.status_code)
301
+ # for other response codes we raise an error - it should be only exotic ones
302
+ response.raise_for_status()
303
+
304
+ def report_to_data(self, report: bytes, validate=True) -> typing.Generator[dict, None, None]:
305
+ try:
306
+ fd = BytesIO(report)
307
+ counter_report = Counter5ReportBase()
308
+ header, data = counter_report.fd_to_dicts(fd)
309
+ except ValueError as e:
310
+ raise SushiException(str(e), content=report)
311
+ if validate:
312
+ self.validate_data(counter_report.errors, counter_report.warnings)
313
+ return data
314
+
315
+ @classmethod
316
+ def validate_data(cls, errors: typing.List[CounterError], warnings: typing.List[CounterError]):
317
+ """Checks that the parsed erorrs and warings are not fatal.
318
+ If so, it will raise SushiException
319
+ :param errors: list of errors
320
+ :param warnings: list of warnings
321
+ :return:
322
+ """
323
+ if len(errors) == 1:
324
+ errors[0].raise_me()
325
+ elif len(errors) >= 1:
326
+ message = '; '.join(
327
+ cls._format_error(error.to_sushi_dict()).full_log for error in errors
328
+ )
329
+ raise SushiException(message, content=[e.data for e in errors])
330
+
331
+ # log warnings
332
+ for warning in warnings:
333
+ logging.warning(
334
+ "Warning Exception in report: %s", cls._format_error(warning.to_sushi_dict())
335
+ )
336
+
337
+ @classmethod
338
+ def extract_errors_from_data(cls, report_data: typing.Union[dict, list]):
339
+ errors: typing.List[SushiError] = []
340
+
341
+ # extract exceptions from list
342
+ if isinstance(report_data, list):
343
+ errors = []
344
+ for item in report_data:
345
+ errors += cls.extract_errors_from_data(item)
346
+ return errors
347
+
348
+ # exception in Exception object
349
+ for exception in recursive_finder(report_data, ["Exception"]):
350
+ errors.append(cls._format_error(exception))
351
+
352
+ # exceptions in Exceptions object
353
+ for exceptions in recursive_finder(report_data, ["Exceptions"]):
354
+ for exception in exceptions:
355
+ errors.append(cls._format_error(exception))
356
+
357
+ if not errors:
358
+ # naked exception in root
359
+ naked = cls._format_error(report_data)
360
+
361
+ # at least severity and code needs to be present
362
+ if naked.code and naked.severity:
363
+ errors.append(naked)
364
+
365
+ return errors
366
+
367
+ @classmethod
368
+ def _format_error(cls, exc: dict):
369
+ code = next(recursive_finder(exc, ["Code"]), None)
370
+ severity = error_code_to_severity(code)
371
+ text = next(recursive_finder(exc, ["Message"]), "")
372
+ data = next(recursive_finder(exc, ["Data"]), "")
373
+
374
+ if code is None:
375
+ # Some responses contains "number" instead of "code"
376
+ code = next(recursive_finder(exc, ["number"]), "")
377
+
378
+ message = f'{severity} error {code}: {text}'
379
+ if data:
380
+ message += f'; {data}'
381
+
382
+ error = SushiError(
383
+ code=code, text=text, full_log=message, severity=severity, raw_data=exc, data=data
384
+ )
385
+ return error
386
+
387
+ def _check_report_type(self, report_type):
388
+ report_type = report_type.lower()
389
+ if '_' in report_type:
390
+ main_type, subtype = report_type.split('_', 1)
391
+ else:
392
+ main_type = report_type
393
+ subtype = None
394
+ if main_type not in self.report_types:
395
+ raise ValueError(f'Report type {main_type} is not supported.')
396
+ if subtype and subtype not in self.report_types[main_type]['subreports']:
397
+ raise ValueError(f'Report subtype {subtype} is not supported for type {main_type}.')
398
+ return report_type
399
+
400
+ def report_to_string(self, report_data):
401
+ return json.dumps(report_data, ensure_ascii=False, indent=2)
402
+
403
+
404
+ class Sushi4Client(SushiClientBase):
405
+
406
+ """
407
+ Client for SUSHI and COUNTER 4 protocol - a simple proxy for the celus_pycounter.sushi
408
+ implementation
409
+ """
410
+
411
+ @staticmethod
412
+ def to_pycounter_report_type(celus_report_type: str) -> str:
413
+ """Celus and pycounter report types may differ"""
414
+ CONVERSIONS = {"JR1GOA": "JR1GOA"}
415
+ return CONVERSIONS.get(celus_report_type, celus_report_type)
416
+
417
+ # remap of extra params into names that have special meaning for the pycounter client
418
+ extra_params_remap = {'Name': 'requestor_name', 'Email': 'requestor_email'}
419
+
420
+ def __init__(self, url, requestor_id, customer_id=None, extra_params=None, auth=None):
421
+ super().__init__(url, requestor_id, customer_id, extra_params, auth)
422
+
423
+ @classmethod
424
+ def _encode_date(cls, value) -> str:
425
+ if hasattr(value, 'isoformat'):
426
+ return value.isoformat()[:7]
427
+ return value[:7]
428
+
429
+ def get_report_data(
430
+ self,
431
+ report_type,
432
+ begin_date,
433
+ end_date,
434
+ output_content: typing.Optional[typing.IO] = None,
435
+ params=None,
436
+ ) -> report.CounterReport:
437
+ kwargs = {'customer_reference': self.customer_id}
438
+ if self.requestor_id:
439
+ kwargs['requestor_id'] = self.requestor_id
440
+ if params:
441
+ # recode params that have special meaning
442
+ for orig, new in self.extra_params_remap.items():
443
+ if orig in params:
444
+ kwargs[new] = params.pop(orig)
445
+ # put the rest in as it is
446
+ kwargs.update(params)
447
+ if self.auth:
448
+ kwargs['auth'] = self.auth
449
+
450
+ report_type = Sushi4Client.to_pycounter_report_type(report_type)
451
+ report = sushi.get_report(
452
+ self.url, begin_date, end_date, report=report_type, dump_file=output_content, **kwargs
453
+ )
454
+ return report
455
+
456
+ @classmethod
457
+ def extract_errors_from_data(cls, report_data: typing.IO[bytes]):
458
+ try:
459
+ report_data.seek(0) # set to start
460
+ content = report_data.read().decode('utf8', 'ignore')
461
+ parsed = xmltodict.parse(content)
462
+ except Exception as e:
463
+ log = f'Exception: {e}\nTraceback: {traceback.format_exc()}'
464
+ return [SushiError(code='non-sushi', text=str(e), full_log=log, severity='Exception')]
465
+ else:
466
+ errors: typing.List[SushiError] = []
467
+ for exception in recursive_finder(parsed, ["Exception"]):
468
+ if not isinstance(exception, dict):
469
+ # skip non-object exceptions
470
+ continue
471
+ code = next(recursive_finder(exception, ["Number"]), "")
472
+ code = code.get("#text", "") if isinstance(code, dict) else code
473
+ message = next(recursive_finder(exception, ["Message"]), "")
474
+ message = message.get("#text", "") if isinstance(message, dict) else message
475
+ severity = error_code_to_severity(code)
476
+
477
+ full_log = f'{severity}: #{code}; {message}'
478
+ errors.append(
479
+ SushiError(
480
+ code=code,
481
+ text=message,
482
+ severity=severity,
483
+ full_log=full_log,
484
+ raw_data=exception,
485
+ )
486
+ )
487
+
488
+ if not errors:
489
+ errors.append(
490
+ SushiError(
491
+ code='non-sushi',
492
+ text='Could not find Exception data in XML, probably wrong format',
493
+ full_log='Could not find Exception data in XML, probably wrong format',
494
+ severity='Exception',
495
+ )
496
+ )
497
+ return errors
498
+
499
+ def report_to_string(self, report_data: report.CounterReport):
500
+ lines = report_data.as_generic()
501
+ out = StringIO()
502
+ writer = csv.writer(out, dialect='excel', delimiter="\t")
503
+ writer.writerows(lines)
504
+ return out.getvalue()