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.
- celus_nigiri-1.3.3/LICENSE +19 -0
- celus_nigiri-1.3.3/PKG-INFO +21 -0
- celus_nigiri-1.3.3/celus_nigiri/__init__.py +3 -0
- celus_nigiri-1.3.3/celus_nigiri/celus.py +80 -0
- celus_nigiri-1.3.3/celus_nigiri/client.py +504 -0
- celus_nigiri-1.3.3/celus_nigiri/counter4.py +122 -0
- celus_nigiri-1.3.3/celus_nigiri/counter5.py +476 -0
- celus_nigiri-1.3.3/celus_nigiri/csv_detect/__init__.py +86 -0
- celus_nigiri-1.3.3/celus_nigiri/csv_detect/__main__.py +26 -0
- celus_nigiri-1.3.3/celus_nigiri/error_codes.py +105 -0
- celus_nigiri-1.3.3/celus_nigiri/exceptions.py +7 -0
- celus_nigiri-1.3.3/celus_nigiri/record.py +57 -0
- celus_nigiri-1.3.3/celus_nigiri/utils.py +45 -0
- celus_nigiri-1.3.3/pyproject.toml +54 -0
|
@@ -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,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()
|