Skip to content

Commit b9b443a

Browse files
committed
fix for inconsistent v4 api responses
1 parent 1428153 commit b9b443a

7 files changed

Lines changed: 369 additions & 222 deletions

File tree

Lines changed: 133 additions & 59 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
1+
import json
12
import os
23
from textwrap import dedent
4+
import xml.etree.ElementTree as ET
35

46
import requests
57

@@ -19,9 +21,12 @@ def get_table_res_field(field):
1921
'Edm.Int32': 'integer', # 123
2022
'Edm.Int16': 'integer', # 123
2123
'Edm.Byte': 'integer', # 123
22-
'Edm.DateTimeOffset': 'datetime', # "2016-02-28T10:22:10.843+02:00"
24+
'Edm.DateTimeOffset': 'datetime', # "2016-02-28T10:22:10.843+02:00",
25+
'Edm.DateTime': 'datetime',
2326
None: 'string', # "string",
2427
'Edm.Boolean': 'boolean',
28+
'Edm.String': 'string',
29+
'Edm.Decimal': 'string',
2530
}
2631
assert field.get("$Type") in field_types, f"unknown field type {field.get('$Type')}"
2732
return {
@@ -30,8 +35,8 @@ def get_table_res_field(field):
3035

3136

3237
def get_parliamentinfo_tables():
33-
data = requests.get('https://knesset.gov.il/OdataV4/ParliamentInfo/$metadata?$format=json').json()
3438
res = {}
39+
data = requests.get('https://knesset.gov.il/OdataV4/ParliamentInfo/$metadata?$format=json').json()
3540
for table_name, fields in data['OdataService.DAL.ParliamentInfo'].items():
3641
assert fields.pop("$Kind") == 'EntityType'
3742
keys = fields.pop("$Key") or []
@@ -42,11 +47,38 @@ def get_parliamentinfo_tables():
4247
field_name: get_table_res_field(field) for field_name, field in fields.items() if field.get('$Kind') is None
4348
}
4449
}
50+
for service_name in (
51+
'Lobbyists',
52+
'Votes',
53+
):
54+
xmlstr = requests.get(f'https://knesset.gov.il/Odata/{service_name}.svc/$metadata').text
55+
root = ET.fromstring(xmlstr)
56+
ns = {
57+
"edmx": "http://schemas.microsoft.com/ado/2007/06/edmx",
58+
"edm": "http://schemas.microsoft.com/ado/2009/11/edm",
59+
}
60+
for schema in root.findall(".//edm:Schema", ns):
61+
for entity_type in schema.findall("edm:EntityType", ns):
62+
table_name = f'{service_name.lower()}_{entity_type.get("Name")}'
63+
fields = {}
64+
for prop in entity_type.findall("edm:Property", ns):
65+
field_name = prop.get("Name")
66+
fields[field_name] = get_table_res_field({
67+
"$Type": prop.get("Type"),
68+
})
69+
edm_keys = entity_type.findall("edm:Key", ns)
70+
assert len(edm_keys) == 1
71+
edm_key = edm_keys[0]
72+
keys = [k.get("Name") for k in edm_key.findall("edm:PropertyRef", ns)]
73+
res[table_name] = {
74+
'primary_key': keys[0] if len(keys) == 1 else keys,
75+
'fields': fields,
76+
}
4577
return res
4678

4779

48-
def get_pipelines_res_field(field):
49-
assert field['source'] == '{name}', f'unexpected source {field["source"]}'
80+
def get_pipelines_res_field(field, pipeline_id=None, field_name=None):
81+
assert field['source'] == '{name}', f'unexpected source {field["source"]} in field {field_name} of pipeline {pipeline_id}'
5082
return {
5183
'type': field['type'],
5284
'primary_key': bool(field.get('primaryKey')),
@@ -56,88 +88,126 @@ def get_pipelines_res_field(field):
5688
def get_parliamentinfo_pipelines():
5789
res = {}
5890
for pipeline in list_pipelines(full=True):
59-
if pipeline.get('dataservice_params') and pipeline['dataservice_params'].get('service-name') == 'api':
60-
res[pipeline['dataservice_params']['method-name']] = {}
61-
res[pipeline['dataservice_params']['method-name']]['fields'] = {
62-
name: get_pipelines_res_field(field) for name, field in pipeline['dataservice_params']['fields'].items()
91+
pipeline_id = pipeline['pipeline_id']
92+
if pipeline.get('dataservice_params'):
93+
dataservice_params = pipeline['dataservice_params']
94+
odata_v4 = dataservice_params.get('odata-v4', True)
95+
if odata_v4:
96+
assert dataservice_params.get('service-name') == 'api', f'{pipeline_id}: api service should have service-name set to api'
97+
method_name = dataservice_params['method-name']
98+
else:
99+
method_name = f'{dataservice_params["service-name"]}_{dataservice_params["method-name"]}'
100+
res[method_name] = {}
101+
res[method_name]['fields'] = {
102+
name: get_pipelines_res_field(field, pipeline_id=pipeline_id, field_name=name)
103+
for name, field in dataservice_params['fields'].items()
63104
}
64-
primary_keys = [name for name, field in res[pipeline['dataservice_params']['method-name']]['fields'].items() if field.get('primary_key')]
65-
assert len(primary_keys) <= 1, f"unexpected number of primary keys in pipeline {pipeline['pipeline_id']}: {primary_keys}"
66-
res[pipeline['dataservice_params']['method-name']]['primary_key'] = primary_keys[0] if primary_keys else None
105+
primary_keys = [name for name, field in res[method_name]['fields'].items() if field.get('primary_key')]
106+
if len(primary_keys) == 1:
107+
res[method_name]['primary_key'] = primary_keys[0]
108+
elif len(primary_keys) > 1:
109+
res[method_name]['primary_key'] = primary_keys
110+
else:
111+
raise Exception(f'no primary key found in pipeline {pipeline_id} for method {method_name}')
67112
return res
68113

69114

70-
class MissingPipeline:
115+
class MissingObject:
71116

72-
def __init__(self, table_name, table):
117+
def __init__(self, dst_obj_type, table_name, table=None):
118+
self.dst_obj_type = dst_obj_type
73119
self.table_name = table_name
74120
self.table = table
75121

76122
def __str__(self):
77-
res = f'table {self.table_name} is missing from pipelines\n'
78-
filename = f'knesset/{self.table_name.lower()}.yaml'
79-
filecontent = dedent(f'''
80-
pipeline-type: knesset dataservice
81-
dataservice-parameters:
82-
service-name: api
83-
method-name: "{self.table_name}"
84-
fields:
85-
''')
86-
for field_name, field in self.table['fields'].items():
87-
filecontent += f' {field_name}:\n'
88-
filecontent += f' source: "{{name}}"\n'
89-
filecontent += f' type: "{field["type"]}"\n'
90-
if field_name == self.table['primary_key']:
91-
filecontent += f' primaryKey: true\n'
92-
with open(os.path.join(os.path.dirname(__file__), '..', 'pipelines', filename), 'w') as f:
93-
f.write(filecontent)
94-
return res
95-
96-
97-
class IncompletePipeline:
98-
99-
def __init__(self, table_name, table, pipeline):
123+
if self.dst_obj_type == 'pipelines':
124+
filename = f'knesset/{self.table_name.lower()}.yaml'
125+
filecontent = dedent(f'''
126+
pipeline-type: knesset dataservice
127+
dataservice-parameters:
128+
service-name: api
129+
method-name: "{self.table_name}"
130+
fields:
131+
''')
132+
for field_name, field in self.table['fields'].items():
133+
filecontent += f' {field_name}:\n'
134+
filecontent += f' source: "{{name}}"\n'
135+
filecontent += f' type: "{field["type"]}"\n'
136+
if field_name == self.table['primary_key']:
137+
filecontent += f' primaryKey: true\n'
138+
with open(os.path.join(os.path.dirname(__file__), '..', 'pipelines', filename), 'w') as f:
139+
f.write(filecontent)
140+
return f'{self.table_name} is missing from {self.dst_obj_type}, wrote to filename {filename}\n'
141+
else:
142+
return f'{self.table_name} exists in pipelines but missing in tables'
143+
144+
145+
class InvalidPipeline:
146+
147+
def __init__(self, dst_obj_type, table_name, table):
148+
self.dst_obj_type = dst_obj_type
100149
self.table_name = table_name
101150
self.table = table
102-
self.pipeline = pipeline
103151
self.missing_fields = []
104152
self.wrong_type_fields = []
153+
self.extra_fields = []
154+
self.primary_key_mismatch = False
105155

106156
def __str__(self):
107-
res = f'pipeline for table {self.table_name} is incomplete:\n'
157+
res = f'mismatch in pipeline for table {self.table_name}:\n' if self.dst_obj_type == 'pipelines' else f'mismatch in table for pipeline {self.table_name}:\n'
108158
if len(self.missing_fields) > 0:
109-
res += ' - missing fields:\n'
159+
res += ' - missing fields in pipeline:\n' if self.dst_obj_type == 'pipelines' else ' - extra fields in pipeline do not appear in table:\n'
110160
for field_name in self.missing_fields:
111-
res += f'{field_name}: {{source: "{{name}}", type: "{self.table["fields"][field_name]["type"]}"}}\n'
161+
res += f'{field_name}: {{source: "{{name}}", type: "{self.table["fields"].get(field_name, {}).get("type")}"}}\n'
112162
if len(self.wrong_type_fields) > 0:
113-
res += ' - wrong type fields:\n'
163+
res += ' - wrong type fields:\n' if self.dst_obj_type == 'pipelines' else ' - wrong type fields (type in table shown below does not match type in pipeline):\n'
114164
for field_name in self.wrong_type_fields:
115165
res += f'{field_name}: {{source: "{{name}}", type: "{self.table["fields"][field_name]["type"]}"}}\n'
166+
if len(self.extra_fields) > 0:
167+
res += ' - extra fields in pipeline which are not in table:\n' if self.dst_obj_type == 'pipelines' else ' - missing fields in pipeline which appear in table:\n'
168+
for field_name in self.extra_fields:
169+
res += f'{field_name}\n'
170+
if self.primary_key_mismatch:
171+
res += ' - primary key mismatch:\n'
172+
res += f'table primary key: {self.table["primary_key"]}\n'
116173
return res
117174

175+
def has_errors(self):
176+
return sum((
177+
len(self.missing_fields),
178+
len(self.wrong_type_fields),
179+
len(self.extra_fields),
180+
)) > 0 or self.primary_key_mismatch
181+
118182

119183
def compare_parliamentinfo_tables_pipelines():
120184
fix_objects = []
121185
tables = get_parliamentinfo_tables()
122186
pipelines = get_parliamentinfo_pipelines()
123-
for table_name, table in tables.items():
124-
if table_name not in pipelines:
125-
fix_objects.append(MissingPipeline(table_name, table))
126-
continue
127-
pipeline = pipelines[table_name]
128-
incomplete_pipeline = IncompletePipeline(table_name, table, pipeline)
129-
for field_name, field in table['fields'].items():
130-
if field_name not in pipeline['fields']:
131-
if table['primary_key'] == field_name and pipeline['primary_key']:
132-
field_name = pipeline['primary_key']
133-
else:
134-
incomplete_pipeline.missing_fields.append(field_name)
135-
continue
136-
pipeline_field = pipeline['fields'][field_name]
137-
if field['type'] != pipeline_field['type'] and field_name not in IGNORE_FIELD_TYPE_ERRORS.get(table_name, []):
138-
incomplete_pipeline.wrong_type_fields.append(field_name)
139-
if len(incomplete_pipeline.missing_fields) > 0 or len(incomplete_pipeline.wrong_type_fields) > 0:
140-
fix_objects.append(incomplete_pipeline)
187+
for dst_obj_type, src, dst in (('pipelines', tables, pipelines), ('tables', pipelines, tables)):
188+
for src_name, src_obj in src.items():
189+
if src_name not in dst:
190+
fix_objects.append(MissingObject(dst_obj_type, src_name, src_obj if dst_obj_type == 'pipelines' else None))
191+
else:
192+
dst_obj = dst[src_name]
193+
invalid_pipeline = InvalidPipeline(dst_obj_type, src_name, tables[src_name])
194+
for field_name, field in src_obj['fields'].items():
195+
if field_name not in dst_obj['fields']:
196+
if dst_obj_type == 'pipelines' and src_obj['primary_key'] == field_name and dst_obj['primary_key']:
197+
# handle the special case of primary key for v4 being renamed to Id
198+
field_name = dst_obj['primary_key']
199+
elif dst_obj_type == 'tables' and src_obj['primary_key'] == field_name and dst_obj['primary_key'] == 'Id':
200+
# handle the special case of primary key for v4 being renamed to Id
201+
field_name = 'Id'
202+
else:
203+
invalid_pipeline.missing_fields.append(field_name)
204+
continue
205+
if field['type'] != dst_obj['fields'][field_name]['type'] and field_name not in IGNORE_FIELD_TYPE_ERRORS.get(src_name, []):
206+
invalid_pipeline.wrong_type_fields.append(field_name)
207+
if set(src_obj['primary_key']) != set(dst_obj['primary_key']) and tables[src_name]['primary_key'] != 'Id':
208+
invalid_pipeline.primary_key_mismatch = True
209+
if invalid_pipeline.has_errors():
210+
fix_objects.append(invalid_pipeline)
141211
if len(fix_objects) > 0:
142212
for fix_object in fix_objects:
143213
print(fix_object)
@@ -148,3 +218,7 @@ def compare_parliamentinfo_tables_pipelines():
148218

149219
if __name__ == '__main__':
150220
compare_parliamentinfo_tables_pipelines()
221+
# print(json.dumps(list(get_parliamentinfo_pipelines().keys()), indent=2))
222+
# print(json.dumps(get_parliamentinfo_pipelines()["lobbyists_V_Lobbyists"], indent=2))
223+
# print(json.dumps(list(get_parliamentinfo_tables().keys()), indent=2))
224+
# print(json.dumps(get_parliamentinfo_tables()["lobbyists_V_Lobbyists"], indent=2))

airflow/knesset_data_pipelines/run_pipeline.py

Lines changed: 63 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,8 @@
1313
from pprint import pprint
1414
from textwrap import dedent
1515
from collections import defaultdict
16+
import pickle
17+
import functools
1618

1719
import requests
1820
import dataflows as DF
@@ -25,7 +27,7 @@
2527

2628

2729
RECOVERABLE_SERVER_ERRORS = [500, 504]
28-
RECOVERABLE_THROTTLE_ERRORS = [503, 403, 504]
30+
RECOVERABLE_THROTTLE_ERRORS = [500, 503, 403, 504]
2931

3032
# these errors indicate that the pipeline cannot run using the standard dataservice flow but have to run via Docker
3133
UNSUPPORTED_PIPELINE_PARAMS_ERRORS = ['pipeline dependencies are not supported', 'unknown pipeline-type: None', 'additional-steps is not supported']
@@ -215,6 +217,20 @@ def get_row_from_entry(params, entry, v4=False):
215217
return {fieldname: get_field_from_entry(fieldname, field, data, v4=v4) for fieldname, field in params['fields'].items()}
216218

217219

220+
def get_key_row_from_entry(params, entry, v4=False):
221+
row = get_row_from_entry(params, entry, v4)
222+
primary_keys = set()
223+
for field_name, field in params['fields'].items():
224+
if field.get('primaryKey'):
225+
primary_keys.add(field_name)
226+
key = ';'.join([str(row[k]) for k in sorted(list(primary_keys))])
227+
assert len(key) > 0, f'unexpected key ({key}): {params} {entry}'
228+
return {
229+
'key': key,
230+
'row': row,
231+
}
232+
233+
218234
def get_next_skiptoken(first_skiptoken, i, processed_entry_ids):
219235
if len(processed_entry_ids) > i:
220236
skiptoken = processed_entry_ids[-(i+1)]
@@ -262,7 +278,7 @@ def add_dataservice_collection_resource_odata_v4(params, proxies, stats, limit_r
262278
num_entries = 0
263279
for entry in (res if isinstance(res, list) else res['value']):
264280
stats['rows'] += 1
265-
yield get_row_from_entry(params, entry, v4=True)
281+
yield get_key_row_from_entry(params, entry, v4=True)
266282
num_entries += 1
267283
skip = num_entries if skip is None else skip + num_entries
268284
if odata_count is None and isinstance(res, dict) and '@odata.count' in res:
@@ -341,7 +357,7 @@ def add_dataservice_collection_resource(params, proxies=None, stats=None, limit_
341357
has_valid_entry_ids = False
342358
if not has_valid_entry_ids or entry_id not in processed_entry_ids:
343359
stats['rows'] += 1
344-
yield get_row_from_entry(params, entry)
360+
yield get_key_row_from_entry(params, entry)
345361
if has_valid_entry_ids:
346362
processed_entry_ids.append(entry_id)
347363
try:
@@ -385,6 +401,42 @@ def get_schema_set_type_kwargs(dataservice_params):
385401
return res
386402

387403

404+
def validate_items_iterator(iterator_func, stats, num_validations=2, max_iterations=5):
405+
print(f'Starting validate_items_iterator (num_validations={num_validations}, max_iterations={max_iterations})')
406+
ok, last_keys = False, None
407+
with tempfile.TemporaryDirectory() as tmpdir:
408+
filename = os.path.join(tmpdir, 'pickle')
409+
for i in range(max_iterations):
410+
stats.clear()
411+
print(f'validate_items_iterator iteration #{i+1}')
412+
if i > 0:
413+
os.unlink(filename)
414+
with open(filename, 'wb') as f:
415+
pickler = pickle.Pickler(f)
416+
cur_keys = set()
417+
for data in iterator_func():
418+
pickler.dump(data['row'])
419+
key = data['key']
420+
assert key not in cur_keys, f'duplicate key {key}'
421+
cur_keys.add(key)
422+
if last_keys is not None and cur_keys == last_keys:
423+
num_validations -= 1
424+
print(f'Validated, remaining validations: {num_validations}')
425+
if num_validations <= 0:
426+
ok = True
427+
break
428+
last_keys = cur_keys
429+
assert ok, f'Failed to validate items iterator after {max_iterations} iterations'
430+
print('Validated all required validations')
431+
with open(filename, 'rb') as f:
432+
unpickler = pickle.Unpickler(f)
433+
while True:
434+
try:
435+
yield unpickler.load()
436+
except EOFError:
437+
break
438+
439+
388440
def _run_pipeline(table_name, storage_url, pipeline_id, storage_path, dataservice_params, limit_rows, pipeline_name, dump_to_path, dump_to_db, dump_to_storage,
389441
stop_on_throttled_error=False, start_url=None, load_from=None):
390442
if stop_on_throttled_error:
@@ -395,7 +447,14 @@ def _run_pipeline(table_name, storage_url, pipeline_id, storage_path, dataservic
395447
print(f'pipeline_id: {pipeline_id}\nstorage_url: {storage_url}\nstorage_path: {storage_path}\ntable_name: {table_name}')
396448
stats = defaultdict(int)
397449
temp_table_name = f'__temp__{table_name}'
398-
rows_iterator = add_dataservice_collection_resource(dataservice_params, stats=stats, limit_rows=limit_rows, stop_on_throttled_error=stop_on_throttled_error, start_url=start_url, load_from=load_from)
450+
rows_iterator = validate_items_iterator(
451+
functools.partial(
452+
add_dataservice_collection_resource,
453+
dataservice_params,
454+
stats=stats, limit_rows=limit_rows, stop_on_throttled_error=stop_on_throttled_error, start_url=start_url, load_from=load_from
455+
),
456+
stats,
457+
)
399458
first_row = next(rows_iterator, None)
400459
if first_row is not None:
401460
DF.Flow(

0 commit comments

Comments
 (0)