xml2db 0.9.0__py3-none-any.whl

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.
xml2db/document.py ADDED
@@ -0,0 +1,650 @@
1
+ import csv
2
+ import datetime
3
+ import logging
4
+ import multiprocessing
5
+ from hashlib import sha1
6
+ from io import BytesIO
7
+ from typing import Union, TYPE_CHECKING, Dict
8
+
9
+ from sqlalchemy import Column, Table, text, select
10
+ from sqlalchemy.engine import Connection
11
+ from sqlalchemy.sql.expression import TextClause
12
+ from lxml import etree
13
+
14
+ if TYPE_CHECKING:
15
+ from .model import DataModel
16
+
17
+ from xml2db.exceptions import DataModelConfigError
18
+ from xml2db.xml_converter import XMLConverter
19
+
20
+ logger = logging.getLogger(__name__)
21
+
22
+
23
+ class Document:
24
+ """A class to represent a single XML file with its data, based on a given XSD.
25
+
26
+ Based on a given DataModel object which represents the data model defined in the XSD, this class deals with \
27
+ the data itself. It allows parsing an XML file to extract the data into the data model format \
28
+ (performing the transforms defined in the DataModel object) and inserting the data into the database.
29
+
30
+ :param model: A `DataModel` object for this document
31
+ """
32
+
33
+ def __init__(self, model: "DataModel"):
34
+ self.model = model
35
+ self.data = {}
36
+ self.xml_file_path = None
37
+
38
+ def parse_xml(
39
+ self,
40
+ xml_file: Union[str, BytesIO],
41
+ xml_file_path: str = None,
42
+ skip_validation: bool = True,
43
+ ) -> None:
44
+ """Parse an XML document and apply transformation corresponding to the target data model
45
+
46
+ This method will first parse the XML file into a dict (document tree) using lxml
47
+ and then compute hash for all nodes based on their content, and finally convert
48
+ the document tree to tables data, creating primary keys and relations, ready to
49
+ be inserted in the database.
50
+
51
+ :param xml_file: the path or the file object of an XML file to parse
52
+ :param xml_file_path: path of the XML file, must be provided if 'xml_file' is provided as a file object \
53
+ (type 'BytesIO'), in order to fill the 'xml2db_input_file_path' column of the root table.
54
+ :param skip_validation: should we validate the document against the schema first? default to skip for \
55
+ backward compatibility
56
+ """
57
+ if type(xml_file) == BytesIO:
58
+ if xml_file_path is None:
59
+ error_message = (
60
+ "If 'xml_file' is provided as a file object (type 'BytesIO') then 'xml_file_path' must be provided "
61
+ "too in order to fill the 'xml2db_input_file_path' column of the root table."
62
+ )
63
+ logger.error(error_message)
64
+ raise ValueError(error_message)
65
+ self.xml_file_path = xml_file_path if xml_file_path is not None else xml_file
66
+
67
+ document_tree = self.model.xml_converter.parse_xml(
68
+ xml_file, self.xml_file_path, skip_validation
69
+ )
70
+
71
+ logger.info(f"Computing records hashes for {self.xml_file_path}")
72
+ self._compute_records_hashes(document_tree)
73
+
74
+ if "document_tree_hook" in self.model.model_config:
75
+ if not callable(self.model.model_config["document_tree_hook"]):
76
+ raise DataModelConfigError(
77
+ "document_tree_hook provided in config must be callable"
78
+ )
79
+ logger.info(f"Running document_tree_hook function for {self.xml_file_path}")
80
+ document_tree = self.model.model_config["document_tree_hook"](document_tree)
81
+
82
+ logger.info(f"Adding records to data model for {self.xml_file_path}")
83
+ self.data = self.doc_tree_to_flat_data(document_tree)
84
+
85
+ logger.debug(self.__repr__())
86
+
87
+ def to_xml(
88
+ self, out_file: str = None, nsmap: dict = None, indent: str = " "
89
+ ) -> etree.Element:
90
+ """Convert a document tree (nested dict) into an XML file
91
+
92
+ :param out_file: If provided, write output to a file.
93
+ :param nsmap: An optional namespace mapping.
94
+ :param indent: A string used as indentin XML output.
95
+ :return: The etree object corresponding to the root XML node.
96
+ """
97
+ converter = XMLConverter(self.model)
98
+ converter.document_tree = self.flat_data_to_doc_tree()
99
+ return converter.to_xml(out_file=out_file, nsmap=nsmap, indent=indent)
100
+
101
+ def _compute_records_hashes(self, node: Dict) -> bytes:
102
+ """Compute the hash of records recursively, taking into account children, for deduplication purpose.
103
+
104
+ :param node: a node of the parsed document tree
105
+ :return: the hash string representation of the node
106
+ """
107
+ if node is None:
108
+ return b""
109
+ h = sha1()
110
+ table = self.model.tables[node["type"]]
111
+ for field_type, name, _ in table.fields:
112
+ if field_type == "col":
113
+ h.update(str(node["content"].get(name, None)).encode("utf-8"))
114
+ elif field_type == "rel1":
115
+ h.update(
116
+ self._compute_records_hashes(node["content"].get(name, [None])[0])
117
+ )
118
+ elif field_type == "reln":
119
+ h_children = [
120
+ self._compute_records_hashes(v)
121
+ for v in node["content"].get(name, [])
122
+ ]
123
+ for h_child in sorted(h_children):
124
+ h.update(h_child)
125
+ node["xml2db_record_hash"] = h.digest()
126
+ return node["xml2db_record_hash"]
127
+
128
+ def doc_tree_to_flat_data(self, document_tree: dict) -> dict:
129
+ """Convert document tree (nested dict) to flat tables data model to prepare database import
130
+
131
+ :param document_tree: A nested dict which represents an XML document
132
+ :returns: A dict containing flat tables
133
+ """
134
+
135
+ def _extract_node(
136
+ node: Dict, pk_parent_node: int, row_number: int, data_model: dict
137
+ ) -> int:
138
+ """Extract nodes recursively
139
+
140
+ :param node: a dict containing a node of the document tree
141
+ :param pk_parent_node: the primary key of its parent node
142
+ :param data_model: the dict to write output to
143
+ :return: the primary key given to this node
144
+ """
145
+
146
+ # get the corresponding table model
147
+ model_table = self.model.tables[node["type"]]
148
+
149
+ # initialize data structure
150
+ if node["type"] not in data_model:
151
+ data_model[node["type"]] = {"next_pk": 1, "records": []}
152
+ if model_table.is_reused:
153
+ data_model[node["type"]]["hashmap"] = {}
154
+ if any(
155
+ [
156
+ rel.other_table.is_reused
157
+ for rel in model_table.relations_n.values()
158
+ ]
159
+ ):
160
+ data_model[node["type"]]["relations_n"] = {
161
+ rel.rel_table_name: {"next_pk": 1, "records": []}
162
+ for rel in model_table.relations_n.values()
163
+ if rel.other_table.is_reused
164
+ }
165
+ data = data_model[node["type"]]
166
+
167
+ hex_hash = str(node["xml2db_record_hash"])
168
+
169
+ # if node is reused and a record with identical hash is already inserted, return its pk
170
+ if model_table.is_reused:
171
+ if hex_hash in data["hashmap"]:
172
+ return data["hashmap"][hex_hash]
173
+
174
+ record = {}
175
+
176
+ # add pk
177
+ record_pk = data["next_pk"]
178
+ record[f"temp_pk_{model_table.name}"] = record_pk
179
+ data["next_pk"] += 1
180
+
181
+ # add parent pk if node is not reused
182
+ if not model_table.is_reused:
183
+ record[f"temp_fk_parent_{model_table.parent.name}"] = pk_parent_node
184
+ if self.model.model_config["row_numbers"]:
185
+ record["xml2db_row_number"] = row_number
186
+
187
+ # build record from fields for columns and n-1 relations
188
+ for field_type, key, _ in model_table.fields:
189
+ if field_type == "col":
190
+ if key in node["content"]:
191
+ if model_table.columns[key].data_type in ["decimal", "float"]:
192
+ val = [float(v) for v in node["content"][key]]
193
+ elif model_table.columns[key].data_type == "integer":
194
+ val = [int(v) for v in node["content"][key]]
195
+ elif model_table.columns[key].data_type == "boolean":
196
+ val = [
197
+ v == "true" or v == "1" for v in node["content"][key]
198
+ ]
199
+ else:
200
+ val = node["content"][key]
201
+
202
+ if len(val) == 1:
203
+ record[key] = val[0]
204
+ else:
205
+ esc_val = [str(v).replace('"', '\\"') for v in val]
206
+ esc_val = [
207
+ f'"{v}"' if "," in v or '"' in v else v for v in esc_val
208
+ ]
209
+ record[key] = ",".join(esc_val)
210
+ else:
211
+ record[key] = None
212
+
213
+ elif field_type == "rel1":
214
+ rel = model_table.relations_1[key]
215
+ if key in node["content"]:
216
+ record[f"temp_{rel.field_name}"] = _extract_node(
217
+ node["content"][key][0],
218
+ record_pk,
219
+ 0,
220
+ data_model,
221
+ )
222
+ else:
223
+ record[f"temp_{rel.field_name}"] = None
224
+
225
+ record["xml2db_record_hash"] = bytes(node["xml2db_record_hash"])
226
+
227
+ # add integration meta data if root table
228
+ if model_table.type_name == self.model.root_table:
229
+ record["xml2db_input_file_path"] = self.xml_file_path
230
+ record["xml2db_processed_at"] = self.model.processed_at
231
+
232
+ # add n-n relationship data for reused children nodes
233
+ for rel in model_table.relations_n.values():
234
+ if rel.name in node["content"]:
235
+ if rel.other_table.is_reused:
236
+ rel_data = data["relations_n"][rel.rel_table_name]
237
+ i = 1
238
+ for rel_child in node["content"][rel.name]:
239
+ rel_row = {
240
+ f"temp_fk_{model_table.name}": record_pk,
241
+ f"temp_fk_{rel.other_table.name}": _extract_node(
242
+ rel_child,
243
+ record_pk,
244
+ i,
245
+ data_model,
246
+ ),
247
+ }
248
+ if self.model.model_config["row_numbers"]:
249
+ rel_row["xml2db_row_number"] = i
250
+ rel_data["records"].append(rel_row)
251
+ i += 1
252
+ else:
253
+ i = 1
254
+ for rel_child in node["content"][rel.name]:
255
+ _extract_node(rel_child, record_pk, i, data_model)
256
+ i += 1
257
+
258
+ data["records"].append(record)
259
+
260
+ if model_table.is_reused:
261
+ data["hashmap"][hex_hash] = record_pk
262
+
263
+ return record_pk
264
+
265
+ flat_tables = {}
266
+ _extract_node(document_tree, 0, 0, flat_tables)
267
+
268
+ return flat_tables
269
+
270
+ def flat_data_to_doc_tree(self) -> dict:
271
+ """Convert the data stored in flat tables into a document tree (nested dict)
272
+
273
+ :return: The document tree (nested dict)
274
+ """
275
+ data_index = {}
276
+
277
+ # convert data to keyed dict for easier access
278
+ temp = (
279
+ ""
280
+ if f"pk_{self.model.tables[self.model.root_table].name}"
281
+ in self.data[self.model.root_table]["records"][0]
282
+ else "temp_"
283
+ )
284
+ for tb in self.model.tables.values():
285
+ data_index[tb.type_name] = {
286
+ "records": {},
287
+ "relations_n": {},
288
+ }
289
+ if tb.type_name in self.data:
290
+ data_index[tb.type_name]["records"] = {
291
+ row[f"{temp}pk_{tb.name}"]: row
292
+ for row in self.data[tb.type_name]["records"]
293
+ }
294
+ for rel in tb.relations_n.values():
295
+ index = {}
296
+ if rel.other_table.is_reused:
297
+ if tb.type_name in self.data:
298
+ for row in self.data[tb.type_name]["relations_n"][
299
+ rel.rel_table_name
300
+ ]["records"]:
301
+ if row[f"{temp}fk_{tb.name}"] not in index:
302
+ index[row[f"{temp}fk_{tb.name}"]] = []
303
+ index[row[f"{temp}fk_{tb.name}"]].append(
304
+ row[f"{temp}fk_{rel.other_table.name}"]
305
+ )
306
+ else:
307
+ if rel.other_table.type_name in self.data:
308
+ for row in self.data[rel.other_table.type_name]["records"]:
309
+ if row[f"{temp}fk_parent_{tb.name}"] not in index:
310
+ index[row[f"{temp}fk_parent_{tb.name}"]] = []
311
+ index[row[f"{temp}fk_parent_{tb.name}"]].append(
312
+ row[f"{temp}pk_{rel.other_table.name}"]
313
+ )
314
+ data_index[tb.type_name]["relations_n"][rel.rel_table_name] = index
315
+
316
+ def _build_node(node_type: str, node_pk: int) -> dict:
317
+ """Build a dict node recursively
318
+
319
+ :param node_type: The node type
320
+ :param node_pk: The node primary key
321
+ :return: A node as a dict
322
+ """
323
+ tb = self.model.tables[node_type]
324
+ node = {
325
+ "type": node_type,
326
+ "content": {},
327
+ }
328
+ record = data_index[node_type]["records"][node_pk]
329
+ for field_type, rel_name, rel in tb.fields:
330
+ if field_type == "col" and record[rel_name] is not None:
331
+ if rel.data_type in [
332
+ "decimal",
333
+ "float",
334
+ ]: # remove trailing ".0" for decimal and float
335
+ node["content"][rel_name] = [
336
+ value.rstrip("0").rstrip(".") if "." in value else value
337
+ for value in str(record[rel_name]).split(",")
338
+ ]
339
+ elif isinstance(record[rel_name], datetime.datetime):
340
+ node["content"][rel_name] = [
341
+ record[rel_name].isoformat(timespec="milliseconds")
342
+ ]
343
+ else:
344
+ node["content"][rel_name] = (
345
+ list(csv.reader([str(record[rel_name])], escapechar="\\"))[
346
+ 0
347
+ ]
348
+ if "," in str(record[rel_name])
349
+ else [str(record[rel_name])]
350
+ )
351
+ elif (
352
+ field_type == "rel1"
353
+ and record[f"{temp}{rel.field_name}"] is not None
354
+ ):
355
+ node["content"][rel_name] = [
356
+ _build_node(
357
+ rel.other_table.type_name, record[f"{temp}{rel.field_name}"]
358
+ )
359
+ ]
360
+ elif (
361
+ field_type == "reln"
362
+ and node_pk
363
+ in data_index[tb.type_name]["relations_n"][rel.rel_table_name]
364
+ ):
365
+ node["content"][rel_name] = [
366
+ _build_node(rel.other_table.type_name, pk)
367
+ for pk in data_index[tb.type_name]["relations_n"][
368
+ rel.rel_table_name
369
+ ][node_pk]
370
+ ]
371
+ return node
372
+
373
+ return _build_node(
374
+ self.model.root_table,
375
+ int(list(data_index[self.model.root_table]["records"].keys())[0]),
376
+ )
377
+
378
+ def insert_into_temp_tables(self) -> None:
379
+ """Insert data into temporary tables
380
+
381
+ (Re)creates temp tables before inserting data.
382
+ """
383
+ logger.info(f"Dropping temp tables if exist for {self.xml_file_path}")
384
+ self.model.drop_all_temp_tables()
385
+
386
+ logger.info(f"Creating temp tables for {self.xml_file_path}")
387
+ self.model.create_all_tables(temp=True)
388
+
389
+ logger.info(f"Inserting data into temporary tables from {self.xml_file_path}")
390
+ for tb in self.model.fk_ordered_tables:
391
+ for query, data in tb.get_insert_temp_records_statements(
392
+ self.data.get(tb.type_name, None)
393
+ ):
394
+ with self.model.engine.begin() as conn:
395
+ conn.execute(query, data)
396
+
397
+ def merge_into_target_tables(self) -> int:
398
+ """Merge data into target data model
399
+
400
+ Execute all update and insert statements needed to merge temporary tables content \
401
+ into target tables, within a transaction.
402
+
403
+ :return: The number of inserted rows
404
+ """
405
+ inserted_rows_count = 0
406
+ with self.model.engine.begin() as conn:
407
+ for tb in self.model.fk_ordered_tables:
408
+ for query in tb.get_merge_temp_records_statements():
409
+ result = conn.execute(query)
410
+ if query.is_insert:
411
+ inserted_rows_count += result.rowcount
412
+ if inserted_rows_count == 0:
413
+ logger.warning("No rows were inserted!")
414
+ else:
415
+ logger.info(f"Inserted rows: {inserted_rows_count}")
416
+
417
+ return inserted_rows_count
418
+
419
+ def insert_into_target_tables(
420
+ self,
421
+ db_semaphore: multiprocessing.Semaphore = None,
422
+ ) -> int:
423
+ """Insert and merge data into the database
424
+
425
+ Insert data into temporary tables and then merge temporary tables into target tables.
426
+
427
+ :param db_semaphore: An optional semaphore to avoid concurrent access to the database. When provided, it will \
428
+ ensure that only one merging operation at a time is performed.
429
+ :return: The number of inserted rows
430
+ """
431
+ try:
432
+ self.model.create_db_schema()
433
+ self.insert_into_temp_tables()
434
+ except Exception as e:
435
+ logger.error(
436
+ f"Error while importing into temporary tables from {self.xml_file_path}"
437
+ )
438
+ logger.error(e)
439
+ raise
440
+ else:
441
+ logger.info(
442
+ f"Merging temporary tables into target tables for {self.xml_file_path}"
443
+ )
444
+ if db_semaphore is not None:
445
+ db_semaphore.acquire()
446
+ try:
447
+ self.model.create_all_tables() # Create target tables if not exist
448
+ inserted_rows = self.merge_into_target_tables()
449
+ except Exception as e:
450
+ logger.error(
451
+ f"Error while merging temporary tables into target tables for {self.xml_file_path}"
452
+ )
453
+ logger.error(e)
454
+ raise
455
+ finally:
456
+ if db_semaphore is not None:
457
+ db_semaphore.release()
458
+ finally:
459
+ logger.info(f"Dropping temporary tables for {self.xml_file_path}")
460
+ self.model.drop_all_temp_tables()
461
+
462
+ return inserted_rows
463
+
464
+ def extract_from_database(
465
+ self,
466
+ root_table_name: str,
467
+ root_select_where: str,
468
+ ) -> dict:
469
+ """Extract a subtree from the database and store it in a flat format
470
+
471
+ :param root_table_name: The root table name to start from
472
+ :param root_select_where: A where clause to apply to this root table
473
+ :return: A shallow dict of flat data tables
474
+ """
475
+
476
+ def _fetch_data(
477
+ sqla_table: Table,
478
+ key_column: Column,
479
+ join_sequence: list[tuple[Column, Table, Column]],
480
+ top_where_clause: TextClause,
481
+ order_by: Union[None, tuple[Column]],
482
+ append_to: list,
483
+ conn: Connection,
484
+ ):
485
+ """Fetch data from a specific table and write fetched rows in a dict keyed by the first row column"""
486
+ quer = select(*(sqla_table.columns.values()))
487
+
488
+ join_sequence = join_sequence.copy()
489
+ if len(join_sequence) > 0:
490
+ left_col, join_tb, right_col = join_sequence.pop()
491
+ sub_quer = select(right_col)
492
+ prev_join_col = left_col
493
+ for left_col, join_tb, right_col in reversed(join_sequence):
494
+ sub_quer = sub_quer.join(join_tb, right_col == prev_join_col)
495
+ prev_join_col = left_col
496
+ sub_quer = sub_quer.where(top_where_clause)
497
+ quer = quer.where(key_column.in_(sub_quer))
498
+ else:
499
+ quer = quer.where(top_where_clause)
500
+
501
+ if order_by:
502
+ quer = quer.order_by(*order_by)
503
+
504
+ col_names = sqla_table.columns.keys()
505
+ for row in conn.execute(quer):
506
+ append_to.append({key: val for key, val in zip(col_names, row)})
507
+
508
+ def _do_extract_table(
509
+ tb,
510
+ top_where_clause,
511
+ parent_table,
512
+ join_sequence,
513
+ res_dict,
514
+ conn,
515
+ ):
516
+ """Fetch tables and relationship tables recursively"""
517
+ if tb.type_name not in res_dict:
518
+ res_dict[tb.type_name] = {"records": []}
519
+ _fetch_data(
520
+ tb.table,
521
+ (
522
+ getattr(tb.table.c, f"pk_{tb.name}")
523
+ if tb.is_reused
524
+ else getattr(tb.table.c, f"fk_parent_{parent_table.name}")
525
+ ),
526
+ join_sequence,
527
+ top_where_clause,
528
+ (
529
+ None
530
+ if tb.is_reused or not tb.data_model.model_config["row_numbers"]
531
+ else (
532
+ getattr(tb.table.c, f"fk_parent_{parent_table.name}"),
533
+ tb.table.c.xml2db_row_number,
534
+ )
535
+ ),
536
+ res_dict[tb.type_name]["records"],
537
+ conn,
538
+ )
539
+ join_root = (
540
+ [(None, tb.table, getattr(tb.table.c, f"pk_{tb.name}"))]
541
+ if parent_table is None
542
+ else []
543
+ )
544
+ if len(tb.relations_n) > 0:
545
+ if "relations_n" not in res_dict[tb.type_name]:
546
+ res_dict[tb.type_name]["relations_n"] = {}
547
+ for rel in tb.relations_n.values():
548
+ if rel.rel_table_name not in res_dict[tb.type_name]["relations_n"]:
549
+ res_dict[tb.type_name]["relations_n"][rel.rel_table_name] = {
550
+ "records": []
551
+ }
552
+ new_join = []
553
+ if not tb.is_reused:
554
+ new_join = [
555
+ (
556
+ getattr(tb.table.c, f"fk_parent_{parent_table.name}"),
557
+ tb.table,
558
+ getattr(tb.table.c, f"pk_{tb.table.name}"),
559
+ )
560
+ ]
561
+ if rel.other_table.is_reused:
562
+ _fetch_data(
563
+ rel.rel_table,
564
+ getattr(rel.rel_table.c, f"fk_{tb.name}"),
565
+ join_sequence + join_root + new_join,
566
+ top_where_clause,
567
+ (
568
+ (
569
+ getattr(rel.rel_table.c, f"fk_{tb.name}"),
570
+ rel.rel_table.c.xml2db_row_number,
571
+ )
572
+ if tb.data_model.model_config["row_numbers"]
573
+ else None
574
+ ),
575
+ res_dict[tb.type_name]["relations_n"][rel.rel_table_name][
576
+ "records"
577
+ ],
578
+ conn,
579
+ )
580
+ new_join = new_join + [
581
+ (
582
+ getattr(rel.rel_table.c, f"fk_{tb.name}"),
583
+ rel.rel_table,
584
+ getattr(rel.rel_table.c, f"fk_{rel.other_table.name}"),
585
+ )
586
+ ]
587
+ _do_extract_table(
588
+ rel.other_table,
589
+ top_where_clause,
590
+ tb,
591
+ join_sequence + join_root + new_join,
592
+ res_dict,
593
+ conn,
594
+ )
595
+ for rel in tb.relations_1.values():
596
+ _do_extract_table(
597
+ rel.other_table,
598
+ top_where_clause,
599
+ tb,
600
+ join_sequence
601
+ + [
602
+ (
603
+ getattr(
604
+ tb.table.c,
605
+ f"pk_{tb.name}"
606
+ if tb.is_reused
607
+ else f"fk_parent_{parent_table.name}",
608
+ ),
609
+ tb.table,
610
+ getattr(tb.table.c, f"{rel.field_name}"),
611
+ )
612
+ ],
613
+ res_dict,
614
+ conn,
615
+ )
616
+
617
+ flat_tables = {}
618
+
619
+ with self.model.engine.connect() as conn:
620
+ _do_extract_table(
621
+ self.model.tables[root_table_name],
622
+ text(root_select_where),
623
+ None,
624
+ [],
625
+ flat_tables,
626
+ conn,
627
+ )
628
+
629
+ self.data = flat_tables
630
+ return flat_tables
631
+
632
+ def __repr__(self) -> str:
633
+ """Output a repr string for the current document with records count"""
634
+ settings = (
635
+ f"temp_prefix: {self.model.temp_prefix}, db_schema: {self.model.db_schema}"
636
+ )
637
+ if not self.data:
638
+ return f"Empty {self.model.data_flow_name} document ({settings})"
639
+ else:
640
+ n = sum([len(v["records"]) for v in self.data.values()])
641
+ return "\n".join(
642
+ [
643
+ f"Parsed {self.xml_file_path} into a {self.model.data_flow_name} document: {n} records",
644
+ f"({settings})",
645
+ ]
646
+ + [
647
+ f" {self.model.tables[k].name}: {len(v['records'])}"
648
+ for k, v in self.data.items()
649
+ ]
650
+ )
xml2db/exceptions.py ADDED
@@ -0,0 +1,4 @@
1
+ class DataModelConfigError(Exception):
2
+ """An exception to raise when model config provided by the user is erroneous"""
3
+
4
+ pass