mario-pipeline-tools 0.1__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.
- mario-pipeline-tools-0.1/PKG-INFO +10 -0
- mario-pipeline-tools-0.1/README.md +10 -0
- mario-pipeline-tools-0.1/mario/__init__.py +0 -0
- mario-pipeline-tools-0.1/mario/data_extractor.py +235 -0
- mario-pipeline-tools-0.1/mario/dataset_builder.py +99 -0
- mario-pipeline-tools-0.1/mario/dataset_specification.py +87 -0
- mario-pipeline-tools-0.1/mario/example.py +28 -0
- mario-pipeline-tools-0.1/mario/metadata.py +113 -0
- mario-pipeline-tools-0.1/mario/utils.py +9 -0
- mario-pipeline-tools-0.1/mario_pipeline_tools.egg-info/PKG-INFO +10 -0
- mario-pipeline-tools-0.1/mario_pipeline_tools.egg-info/SOURCES.txt +18 -0
- mario-pipeline-tools-0.1/mario_pipeline_tools.egg-info/dependency_links.txt +1 -0
- mario-pipeline-tools-0.1/mario_pipeline_tools.egg-info/requires.txt +4 -0
- mario-pipeline-tools-0.1/mario_pipeline_tools.egg-info/top_level.txt +1 -0
- mario-pipeline-tools-0.1/setup.cfg +4 -0
- mario-pipeline-tools-0.1/setup.py +20 -0
- mario-pipeline-tools-0.1/test/test_data_extractor.py +53 -0
- mario-pipeline-tools-0.1/test/test_dataset_specification.py +11 -0
- mario-pipeline-tools-0.1/test/test_integrated.py +27 -0
- mario-pipeline-tools-0.1/test/test_metadata.py +24 -0
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
Metadata-Version: 2.1
|
|
2
|
+
Name: mario-pipeline-tools
|
|
3
|
+
Version: 0.1
|
|
4
|
+
Summary: Base classes and helpers for common data pipeline tasks
|
|
5
|
+
Home-page: https://github.com/JiscDACT/mario
|
|
6
|
+
Author: scottbw
|
|
7
|
+
Author-email: scott.wilson@jisc.ac.uk
|
|
8
|
+
License: all rights reserved
|
|
9
|
+
|
|
10
|
+
A set of base classes and helpers for common data pipeline tasks, whether using in local control scripts or in Airflow tasks.
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
# General purpose data pipelines
|
|
2
|
+
|
|
3
|
+
A set of base classes and helpers for
|
|
4
|
+
common data pipeline tasks, whether using
|
|
5
|
+
in local control scripts or in Airflow tasks.
|
|
6
|
+
|
|
7
|
+
## !WARNING!
|
|
8
|
+
This is a public repository for ease of deployment.
|
|
9
|
+
Be especially careful not to commit anything
|
|
10
|
+
that shouldn't be here.
|
|
File without changes
|
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
import shutil
|
|
3
|
+
|
|
4
|
+
import pandas as pd
|
|
5
|
+
import pantab
|
|
6
|
+
from pandas import DataFrame
|
|
7
|
+
from tableauhyperapi import TableName
|
|
8
|
+
|
|
9
|
+
from mario.dataset_specification import DatasetSpecification
|
|
10
|
+
from mario.metadata import Metadata
|
|
11
|
+
|
|
12
|
+
logger = logging.getLogger(__name__)
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class Configuration:
|
|
16
|
+
"""
|
|
17
|
+
Configuration for a data extractor. This can include:
|
|
18
|
+
* A connection string: an ODBC connection string for SQL extraction
|
|
19
|
+
* A view name: A view as the base for the SQL code (H+, TDSA only)
|
|
20
|
+
* A hook: An object or function used to access data, e.g. S3
|
|
21
|
+
* A file path: A data file to load as the source of data, e.g. data.csv
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
def __init__(self,
|
|
25
|
+
connection_string: str = None,
|
|
26
|
+
hook=None,
|
|
27
|
+
view: str = None,
|
|
28
|
+
file_path: str = None
|
|
29
|
+
):
|
|
30
|
+
self.connection_string = connection_string
|
|
31
|
+
self.hook = hook
|
|
32
|
+
self.view = view
|
|
33
|
+
self.file_path = file_path
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class DataExtractor:
|
|
37
|
+
|
|
38
|
+
def __init__(self,
|
|
39
|
+
configuration: Configuration,
|
|
40
|
+
dataset_specification: DatasetSpecification,
|
|
41
|
+
metadata: Metadata):
|
|
42
|
+
self.configuration = configuration
|
|
43
|
+
self.dataset_specification = dataset_specification
|
|
44
|
+
self.metadata = metadata
|
|
45
|
+
self._data = pd.DataFrame()
|
|
46
|
+
if configuration.file_path is not None:
|
|
47
|
+
if configuration.file_path.endswith('.csv'):
|
|
48
|
+
self.__load_from_csv__()
|
|
49
|
+
elif configuration.file_path.endswith('.hyper'):
|
|
50
|
+
self.__load_from_hyper__()
|
|
51
|
+
else:
|
|
52
|
+
raise ValueError("Unsupported file type")
|
|
53
|
+
elif configuration.hook is not None:
|
|
54
|
+
# TODO something here for Airflow hooks
|
|
55
|
+
pass
|
|
56
|
+
else:
|
|
57
|
+
self.__load_from_sql__()
|
|
58
|
+
|
|
59
|
+
def __load_from_sql__(self):
|
|
60
|
+
query = self.__build_query__()
|
|
61
|
+
# TODO connect using ODBC connection string
|
|
62
|
+
# TODO run query using pyodbc
|
|
63
|
+
logger.info("Executing query: " + query)
|
|
64
|
+
|
|
65
|
+
def __build_query__(self):
|
|
66
|
+
query = ''
|
|
67
|
+
if self.configuration.view is not None:
|
|
68
|
+
query = 'SELECT * FROM ' + self.configuration.view + ' WITH(NOLOCK)'
|
|
69
|
+
else:
|
|
70
|
+
# TODO: Automation 2.0 or TDSA query builder
|
|
71
|
+
pass
|
|
72
|
+
return query
|
|
73
|
+
|
|
74
|
+
def __load_from_csv__(self):
|
|
75
|
+
self._data = pd.read_csv(self.configuration.file_path)
|
|
76
|
+
|
|
77
|
+
def __load_from_hyper__(self):
|
|
78
|
+
from tableau_builder import hyper_utils
|
|
79
|
+
table_parts = hyper_utils.get_default_table_and_schema(hyper_path=self.configuration.file_path)
|
|
80
|
+
table = TableName(table_parts['schema'], table_parts['table'])
|
|
81
|
+
self._data = pantab.frame_from_hyper(
|
|
82
|
+
source=self.configuration.file_path,
|
|
83
|
+
table=table
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
def __minimise_data__(self):
|
|
87
|
+
""" Minimise data so we only keep the columns in the spec """
|
|
88
|
+
columns_to_keep = []
|
|
89
|
+
for item in self.dataset_specification.items:
|
|
90
|
+
if not self.metadata.get_metadata(item).get_property('formula'):
|
|
91
|
+
columns_to_keep.append(item)
|
|
92
|
+
self._data = self._data[columns_to_keep]
|
|
93
|
+
|
|
94
|
+
def validate_data(self):
|
|
95
|
+
validation_errors = []
|
|
96
|
+
for item in self.dataset_specification.items:
|
|
97
|
+
metadata = self.metadata.get_metadata(item)
|
|
98
|
+
# Ignore calculated fields
|
|
99
|
+
if metadata.get_property('formula') is None:
|
|
100
|
+
# Check all columns present
|
|
101
|
+
if item not in self._data.columns:
|
|
102
|
+
validation_errors.append("Item missing: " + item)
|
|
103
|
+
else:
|
|
104
|
+
# Check domain
|
|
105
|
+
if metadata.get_property('domain') is not None:
|
|
106
|
+
data_domain = self._data[item].unique()
|
|
107
|
+
metadata_domain = metadata.get_property('domain')
|
|
108
|
+
for element in data_domain:
|
|
109
|
+
if element not in metadata_domain:
|
|
110
|
+
validation_errors.append("Domain validation failed for " + item)
|
|
111
|
+
logger.error("Validation error: '" + str(element) + "' is not in domain of " + item)
|
|
112
|
+
# Check range
|
|
113
|
+
if metadata.get_property('range') is not None:
|
|
114
|
+
min_value = metadata.get_property('range')[0]
|
|
115
|
+
max_value = metadata.get_property('range')[1]
|
|
116
|
+
data_min = self._data[item].min()
|
|
117
|
+
data_max = self._data[item].max()
|
|
118
|
+
if data_min < min_value:
|
|
119
|
+
validation_errors.append("Range validation failed for " + item)
|
|
120
|
+
logger.error("Validation error: '" + str(data_min) + "' is less than " + str(min_value))
|
|
121
|
+
if data_max > max_value:
|
|
122
|
+
validation_errors.append("Range validation failed for " + item)
|
|
123
|
+
logger.error("Validation error: '" + str(data_max) + "' is greater than " + str(max_value))
|
|
124
|
+
|
|
125
|
+
if len(validation_errors) != 0:
|
|
126
|
+
for validation_error in validation_errors:
|
|
127
|
+
logger.error(validation_error)
|
|
128
|
+
raise ValueError("Data validation failed - check the logs for details")
|
|
129
|
+
return True
|
|
130
|
+
|
|
131
|
+
def get_data_frame(self) -> DataFrame:
|
|
132
|
+
return self._data
|
|
133
|
+
|
|
134
|
+
def save_data_as_excel(self, file_path: str, minimise=True):
|
|
135
|
+
if minimise:
|
|
136
|
+
self.__minimise_data__()
|
|
137
|
+
# TODO use OpenPyxl here, or drop this method
|
|
138
|
+
self._data.to_csv(file_path)
|
|
139
|
+
pass
|
|
140
|
+
|
|
141
|
+
def save_data_as_csv(self, file_path: str, minimise=True):
|
|
142
|
+
if minimise:
|
|
143
|
+
self.__minimise_data__()
|
|
144
|
+
self._data.to_csv(file_path)
|
|
145
|
+
|
|
146
|
+
def save_data_as_hyper(self, file_path: str, table: str = 'Extract', schema: str = 'Extract', minimise=True):
|
|
147
|
+
table_name = TableName(schema, table)
|
|
148
|
+
if minimise:
|
|
149
|
+
self.__minimise_data__()
|
|
150
|
+
pantab.frame_to_hyper(df=self._data, database=file_path, table=table_name)
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
class HyperFile(DataExtractor):
|
|
154
|
+
"""
|
|
155
|
+
Wrapper for a HyperFile as a data extractor - use when no data needs to be extracted,
|
|
156
|
+
and we just want to treat a hyper as a hyper with no conversion to/from dataframe
|
|
157
|
+
"""
|
|
158
|
+
|
|
159
|
+
def __init__(self,
|
|
160
|
+
configuration: Configuration,
|
|
161
|
+
dataset_specification: DatasetSpecification,
|
|
162
|
+
metadata: Metadata):
|
|
163
|
+
super().__init__(configuration, dataset_specification, metadata)
|
|
164
|
+
|
|
165
|
+
def validate_data(self):
|
|
166
|
+
from tableau_builder.hyper_utils import get_default_table_and_schema, check_column_exists, check_type, \
|
|
167
|
+
check_domain, check_range
|
|
168
|
+
|
|
169
|
+
validation_errors = []
|
|
170
|
+
|
|
171
|
+
table_schema = get_default_table_and_schema(self.configuration.file_path)
|
|
172
|
+
table = table_schema['table']
|
|
173
|
+
schema = table_schema['schema']
|
|
174
|
+
|
|
175
|
+
for item in self.dataset_specification.items:
|
|
176
|
+
metadata = self.metadata.get_metadata(item)
|
|
177
|
+
if metadata.get_property('formula') is None:
|
|
178
|
+
if not check_column_exists(
|
|
179
|
+
hyper_path=self.configuration.file_path,
|
|
180
|
+
column_name=item,
|
|
181
|
+
table_name=table,
|
|
182
|
+
schema_name=schema
|
|
183
|
+
):
|
|
184
|
+
validation_errors.append("Item missing: " + item)
|
|
185
|
+
|
|
186
|
+
if metadata.get_property('datatype') and not check_type(
|
|
187
|
+
hyper_path=self.configuration.file_path,
|
|
188
|
+
column_name=item,
|
|
189
|
+
expected_type=metadata.get_property('datatype'),
|
|
190
|
+
table_name=table,
|
|
191
|
+
schema_name=schema
|
|
192
|
+
):
|
|
193
|
+
validation_errors.append("Item is wrong type: " + item)
|
|
194
|
+
|
|
195
|
+
if metadata.get_property('domain') is not None:
|
|
196
|
+
if not check_domain(
|
|
197
|
+
hyper_path=self.configuration.file_path,
|
|
198
|
+
field=item,
|
|
199
|
+
domain=metadata.get_property('domain'),
|
|
200
|
+
table_name=table,
|
|
201
|
+
schema_name=schema
|
|
202
|
+
):
|
|
203
|
+
validation_errors.append("Domain validation failed for " + item)
|
|
204
|
+
if metadata.get_property('range') is not None:
|
|
205
|
+
if not check_range(
|
|
206
|
+
hyper_path=self.configuration.file_path,
|
|
207
|
+
field=item,
|
|
208
|
+
min_value=metadata.get_property('range')[0],
|
|
209
|
+
max_value=metadata.get_property('range')[1],
|
|
210
|
+
table_name=table,
|
|
211
|
+
schema_name=schema
|
|
212
|
+
):
|
|
213
|
+
validation_errors.append("Range validation failed for " + item)
|
|
214
|
+
|
|
215
|
+
if len(validation_errors) != 0:
|
|
216
|
+
for validation_error in validation_errors:
|
|
217
|
+
logger.error(validation_error)
|
|
218
|
+
raise ValueError("Domain validation failed - check the logs for details")
|
|
219
|
+
return True
|
|
220
|
+
|
|
221
|
+
def __minimise_data__(self):
|
|
222
|
+
from tableau_builder import hyper_utils
|
|
223
|
+
columns_to_keep = self.dataset_specification.items
|
|
224
|
+
table = hyper_utils.get_default_table_and_schema(self.configuration.file_path)
|
|
225
|
+
hyper_utils.subset_columns(
|
|
226
|
+
columns_to_keep=columns_to_keep,
|
|
227
|
+
hyper_path=self.configuration.file_path,
|
|
228
|
+
schema_name=table['schema'],
|
|
229
|
+
table_name=table['table']
|
|
230
|
+
)
|
|
231
|
+
|
|
232
|
+
def save_data_as_hyper(self, file_path: str, table: str = 'Extract', schema: str = 'Extract', minimise=True):
|
|
233
|
+
if minimise:
|
|
234
|
+
self.__minimise_data__()
|
|
235
|
+
shutil.copyfile(self.configuration.file_path, file_path)
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
import os.path
|
|
3
|
+
import shutil
|
|
4
|
+
import tempfile
|
|
5
|
+
from enum import Enum
|
|
6
|
+
|
|
7
|
+
from tableau_builder.hyper_utils import get_default_table_and_schema
|
|
8
|
+
from tableau_builder.json_metadata import JsonRepository
|
|
9
|
+
|
|
10
|
+
from mario.data_extractor import DataExtractor
|
|
11
|
+
from mario.dataset_specification import DatasetSpecification
|
|
12
|
+
from mario.metadata import Metadata
|
|
13
|
+
|
|
14
|
+
logger = logging.getLogger(__name__)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class Format(Enum):
|
|
18
|
+
TABLEAU_PACKAGED_DATASOURCE = 'tdsx'
|
|
19
|
+
POWER_BI_PACKAGE = 'pbix'
|
|
20
|
+
EXCEL_PIVOT = 'xlsx'
|
|
21
|
+
CSV = 'csv'
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class DatasetBuilder:
|
|
25
|
+
|
|
26
|
+
def __init__(self,
|
|
27
|
+
dataset_specification: DatasetSpecification = None,
|
|
28
|
+
metadata: Metadata = None,
|
|
29
|
+
data: DataExtractor = None
|
|
30
|
+
):
|
|
31
|
+
self.dataset_specification = dataset_specification
|
|
32
|
+
self.metadata = metadata
|
|
33
|
+
self.data = data
|
|
34
|
+
|
|
35
|
+
def validate_metadata(self):
|
|
36
|
+
""" Checks whether the metadata matches the specification"""
|
|
37
|
+
not_included = []
|
|
38
|
+
fields = self.dataset_specification.items
|
|
39
|
+
|
|
40
|
+
for field in fields:
|
|
41
|
+
try:
|
|
42
|
+
self.metadata.get_metadata(field)
|
|
43
|
+
except ValueError:
|
|
44
|
+
not_included.append(field)
|
|
45
|
+
|
|
46
|
+
if len(not_included) > 0:
|
|
47
|
+
logger.error(self.dataset_specification.name + ": Some metadata is missing")
|
|
48
|
+
logger.error(not_included)
|
|
49
|
+
return False
|
|
50
|
+
else:
|
|
51
|
+
logger.info(self.dataset_specification.name + ": All metadata present")
|
|
52
|
+
return True
|
|
53
|
+
|
|
54
|
+
def build(self, output_format: Format, file_path: str):
|
|
55
|
+
if output_format == Format.TABLEAU_PACKAGED_DATASOURCE:
|
|
56
|
+
self.__build_tdsx__(file_path)
|
|
57
|
+
|
|
58
|
+
def __build_excel_pivot__(self, file_path: str):
|
|
59
|
+
# TODO export Excel and Info sheet using OpenPyxl - see code in Automation2.0 and TDSA.
|
|
60
|
+
pass
|
|
61
|
+
|
|
62
|
+
def __build_csv__(self, file_path: str):
|
|
63
|
+
# TODO export Info sheet as well - see code in Automation2.0 and TDSA.
|
|
64
|
+
self.data.save_data_as_csv(file_path=file_path)
|
|
65
|
+
|
|
66
|
+
def __build_tdsx__(self, file_path: str):
|
|
67
|
+
with tempfile.TemporaryDirectory() as temp_folder:
|
|
68
|
+
|
|
69
|
+
# Metadata - convert to internal format used for Tableau
|
|
70
|
+
metadata_file_path = os.path.join(temp_folder, 'metadata.json')
|
|
71
|
+
self.metadata.save(metadata_file_path)
|
|
72
|
+
repository = JsonRepository(metadata_file_path)
|
|
73
|
+
|
|
74
|
+
# Move the hyper extract
|
|
75
|
+
data_file_path = os.path.join(temp_folder, 'data.hyper')
|
|
76
|
+
self.data.save_data_as_hyper(file_path=data_file_path)
|
|
77
|
+
|
|
78
|
+
# Get the table and schema name from the extract
|
|
79
|
+
table_schema = get_default_table_and_schema(data_file_path)
|
|
80
|
+
|
|
81
|
+
# Save the spec
|
|
82
|
+
dataset_file_path = os.path.join(temp_folder, 'dataset.json')
|
|
83
|
+
self.dataset_specification.save(dataset_file_path)
|
|
84
|
+
|
|
85
|
+
# Build a tdsx in a temp file
|
|
86
|
+
output_path = os.path.join(temp_folder, 'build')
|
|
87
|
+
from tableau_builder.dataset import create_tdsx, HYPER
|
|
88
|
+
create_tdsx(
|
|
89
|
+
dataset_file=dataset_file_path,
|
|
90
|
+
metadata_repository=repository,
|
|
91
|
+
data_file=data_file_path,
|
|
92
|
+
output_file=output_path,
|
|
93
|
+
data_source_type=HYPER,
|
|
94
|
+
table_name=table_schema['table'],
|
|
95
|
+
schema_name=table_schema['schema'],
|
|
96
|
+
use_metadata_groups=True
|
|
97
|
+
)
|
|
98
|
+
shutil.copyfile(src=output_path + '.tdsx', dst=file_path)
|
|
99
|
+
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import json
|
|
2
|
+
from typing import List
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
class DatasetSpecification:
|
|
6
|
+
"""
|
|
7
|
+
Base class/interface for dataset specifications. Use an implementation specific to the
|
|
8
|
+
format of specification used.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
def __init__(self):
|
|
12
|
+
self._name = ''
|
|
13
|
+
self._collection: str = ''
|
|
14
|
+
self._measures: List[str] = []
|
|
15
|
+
self._dimensions: List[str] = []
|
|
16
|
+
self._properties = []
|
|
17
|
+
|
|
18
|
+
@property
|
|
19
|
+
def name(self):
|
|
20
|
+
return self._name
|
|
21
|
+
|
|
22
|
+
@name.setter
|
|
23
|
+
def name(self, value):
|
|
24
|
+
self._name = value
|
|
25
|
+
|
|
26
|
+
@property
|
|
27
|
+
def collection(self):
|
|
28
|
+
return self._collection
|
|
29
|
+
|
|
30
|
+
@collection.setter
|
|
31
|
+
def collection(self, value):
|
|
32
|
+
self._collection = value
|
|
33
|
+
|
|
34
|
+
@property
|
|
35
|
+
def enquiry(self):
|
|
36
|
+
return ''
|
|
37
|
+
|
|
38
|
+
@property
|
|
39
|
+
def measures(self):
|
|
40
|
+
return self._measures
|
|
41
|
+
|
|
42
|
+
@measures.setter
|
|
43
|
+
def measures(self, value):
|
|
44
|
+
self._measures = value
|
|
45
|
+
|
|
46
|
+
@property
|
|
47
|
+
def dimensions(self):
|
|
48
|
+
return self._dimensions
|
|
49
|
+
|
|
50
|
+
@dimensions.setter
|
|
51
|
+
def dimensions(self, value):
|
|
52
|
+
self._dimensions = value
|
|
53
|
+
|
|
54
|
+
@property
|
|
55
|
+
def items(self):
|
|
56
|
+
return self.dimensions + self.measures
|
|
57
|
+
|
|
58
|
+
def set_property(self, name, value):
|
|
59
|
+
self._properties[name] = value
|
|
60
|
+
|
|
61
|
+
def get_property(self, name):
|
|
62
|
+
if name in self._properties:
|
|
63
|
+
return self._properties[name]
|
|
64
|
+
return None
|
|
65
|
+
|
|
66
|
+
def save(self, file_path: str):
|
|
67
|
+
json_representation = {
|
|
68
|
+
"name": self.name,
|
|
69
|
+
"collection": self.collection,
|
|
70
|
+
"dimensions": self.dimensions,
|
|
71
|
+
"measures": self.measures
|
|
72
|
+
}
|
|
73
|
+
with open(file_path, 'w') as file:
|
|
74
|
+
json.dump(json_representation, file, default=vars)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def dataset_from_json(file_path: str = None) -> DatasetSpecification:
|
|
78
|
+
with open(file_path, mode='r') as source_file:
|
|
79
|
+
spec = json.load(source_file)
|
|
80
|
+
|
|
81
|
+
dataset_specification = DatasetSpecification()
|
|
82
|
+
dataset_specification.name = spec['name']
|
|
83
|
+
dataset_specification.collection = spec['collection']
|
|
84
|
+
dataset_specification.measures = spec['measures']
|
|
85
|
+
dataset_specification.dimensions = spec['dimensions']
|
|
86
|
+
|
|
87
|
+
return dataset_specification
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import os
|
|
2
|
+
|
|
3
|
+
from mario.data_extractor import DataExtractor, Configuration
|
|
4
|
+
from mario.dataset_specification import dataset_from_json
|
|
5
|
+
from mario.dataset_builder import DatasetBuilder, Format
|
|
6
|
+
from mario.metadata import metadata_from_json
|
|
7
|
+
|
|
8
|
+
# Build a TD extract in Excel
|
|
9
|
+
dataset = dataset_from_json("data_spec.xlsx")
|
|
10
|
+
metadata = metadata_from_json('metadata.json')
|
|
11
|
+
configuration = Configuration(connection_string=os.environ.get('odbc_connection_string'))
|
|
12
|
+
extractor = DataExtractor(configuration=configuration, dataset_specification=dataset, metadata=metadata)
|
|
13
|
+
# Either put automation code in here, or override the base class
|
|
14
|
+
builder = DatasetBuilder(dataset_specification=dataset, metadata=metadata, data=extractor)
|
|
15
|
+
path = os.path.join(dataset.enquiry, dataset.name + '.xlsx')
|
|
16
|
+
builder.build(file_path=path, output_format=Format.EXCEL_PIVOT)
|
|
17
|
+
|
|
18
|
+
# Build a Heidi Plus Datasource in TDSX
|
|
19
|
+
dataset = dataset_from_json("dataset.json")
|
|
20
|
+
metadata = metadata_from_json('metadata.json')
|
|
21
|
+
configuration = Configuration(
|
|
22
|
+
connection_string=os.environ.get('odbc_connection_string'),
|
|
23
|
+
view='dbo.v_heidi_plus_student_fpe'
|
|
24
|
+
)
|
|
25
|
+
extractor = DataExtractor(configuration=configuration, dataset_specification=dataset, metadata=metadata)
|
|
26
|
+
builder = DatasetBuilder(dataset_specification=dataset, metadata=metadata, data=extractor)
|
|
27
|
+
path = os.path.join('datasources', dataset.collection, dataset.name + '.tdsx')
|
|
28
|
+
builder.build(file_path=path, output_format=Format.TABLEAU_PACKAGED_DATASOURCE)
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
import json
|
|
2
|
+
from typing import List
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
class Item:
|
|
6
|
+
|
|
7
|
+
def __init__(self):
|
|
8
|
+
self._name = ''
|
|
9
|
+
self._description = ''
|
|
10
|
+
self._properties = {}
|
|
11
|
+
|
|
12
|
+
@property
|
|
13
|
+
def name(self):
|
|
14
|
+
return self._name
|
|
15
|
+
|
|
16
|
+
@name.setter
|
|
17
|
+
def name(self, value):
|
|
18
|
+
self._name = value
|
|
19
|
+
|
|
20
|
+
@property
|
|
21
|
+
def description(self):
|
|
22
|
+
return self._description
|
|
23
|
+
|
|
24
|
+
@description.setter
|
|
25
|
+
def description(self, value):
|
|
26
|
+
self._description = value
|
|
27
|
+
|
|
28
|
+
def set_property(self, name, value):
|
|
29
|
+
self._properties[name] = value
|
|
30
|
+
|
|
31
|
+
def get_property(self, name):
|
|
32
|
+
if name in self._properties:
|
|
33
|
+
return self._properties[name]
|
|
34
|
+
return None
|
|
35
|
+
|
|
36
|
+
def to_json(self):
|
|
37
|
+
json_representation = {
|
|
38
|
+
"name": self.name,
|
|
39
|
+
"description": self.description
|
|
40
|
+
}
|
|
41
|
+
for key, value in self._properties.items():
|
|
42
|
+
json_representation[key] = value
|
|
43
|
+
return json_representation
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class Metadata(Item):
|
|
47
|
+
|
|
48
|
+
def __init__(self, name: str = None):
|
|
49
|
+
super().__init__()
|
|
50
|
+
self._items: List[Item] = []
|
|
51
|
+
self.name = name
|
|
52
|
+
if self.name is None:
|
|
53
|
+
self.name = 'Metadata'
|
|
54
|
+
|
|
55
|
+
def get_metadata(self, name: str):
|
|
56
|
+
for item in self._items:
|
|
57
|
+
if item.name == name:
|
|
58
|
+
return item
|
|
59
|
+
return None
|
|
60
|
+
|
|
61
|
+
@property
|
|
62
|
+
def items(self):
|
|
63
|
+
return self._items
|
|
64
|
+
|
|
65
|
+
def add_item(self, item: Item) -> None:
|
|
66
|
+
self._items.append(item)
|
|
67
|
+
|
|
68
|
+
def merge_items(self, metadata) -> None:
|
|
69
|
+
for item in metadata.items:
|
|
70
|
+
self.add_item(item)
|
|
71
|
+
|
|
72
|
+
def save(self, file_path: str = None) -> None:
|
|
73
|
+
json_representation = {
|
|
74
|
+
"collection": {
|
|
75
|
+
"name": self.name,
|
|
76
|
+
"items": []
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
for item in self._items:
|
|
80
|
+
json_representation['collection']['items'].append(item.to_json())
|
|
81
|
+
|
|
82
|
+
with open(file_path, mode='w') as file:
|
|
83
|
+
json.dump(json_representation, file, default=vars)
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def metadata_from_json(file_path: str = None):
|
|
87
|
+
""" Factory method for creating a Metadata instance from a JSON file"""
|
|
88
|
+
metadata = Metadata()
|
|
89
|
+
|
|
90
|
+
with open(file_path) as metadata_file:
|
|
91
|
+
metadata_json = json.load(metadata_file)
|
|
92
|
+
|
|
93
|
+
if 'collection' in metadata_json:
|
|
94
|
+
collection = 'collection'
|
|
95
|
+
items = 'items'
|
|
96
|
+
name = 'name'
|
|
97
|
+
else:
|
|
98
|
+
collection = 'datasource'
|
|
99
|
+
items = 'fields'
|
|
100
|
+
name = 'fieldName'
|
|
101
|
+
|
|
102
|
+
metadata.name = metadata_json[collection]['name']
|
|
103
|
+
|
|
104
|
+
for item in metadata_json[collection][items]:
|
|
105
|
+
metadata_item = Item()
|
|
106
|
+
metadata_item.name = item[name]
|
|
107
|
+
metadata_item.description = item['description']
|
|
108
|
+
for prop in item:
|
|
109
|
+
if prop not in [name, 'description']:
|
|
110
|
+
metadata_item.set_property(prop, item[prop])
|
|
111
|
+
metadata.add_item(metadata_item)
|
|
112
|
+
|
|
113
|
+
return metadata
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import os
|
|
2
|
+
from datetime import datetime
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
def append_current_date_to_file_name(file_name: str) -> str:
|
|
6
|
+
date_time = datetime.now().strftime("%Y_%m_%d")
|
|
7
|
+
filename = os.path.splitext(os.path.basename(file_name))[0]
|
|
8
|
+
extension = os.path.splitext(os.path.basename(file_name))[1]
|
|
9
|
+
return filename + '_' + date_time + extension
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
Metadata-Version: 2.1
|
|
2
|
+
Name: mario-pipeline-tools
|
|
3
|
+
Version: 0.1
|
|
4
|
+
Summary: Base classes and helpers for common data pipeline tasks
|
|
5
|
+
Home-page: https://github.com/JiscDACT/mario
|
|
6
|
+
Author: scottbw
|
|
7
|
+
Author-email: scott.wilson@jisc.ac.uk
|
|
8
|
+
License: all rights reserved
|
|
9
|
+
|
|
10
|
+
A set of base classes and helpers for common data pipeline tasks, whether using in local control scripts or in Airflow tasks.
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
README.md
|
|
2
|
+
setup.py
|
|
3
|
+
mario/__init__.py
|
|
4
|
+
mario/data_extractor.py
|
|
5
|
+
mario/dataset_builder.py
|
|
6
|
+
mario/dataset_specification.py
|
|
7
|
+
mario/example.py
|
|
8
|
+
mario/metadata.py
|
|
9
|
+
mario/utils.py
|
|
10
|
+
mario_pipeline_tools.egg-info/PKG-INFO
|
|
11
|
+
mario_pipeline_tools.egg-info/SOURCES.txt
|
|
12
|
+
mario_pipeline_tools.egg-info/dependency_links.txt
|
|
13
|
+
mario_pipeline_tools.egg-info/requires.txt
|
|
14
|
+
mario_pipeline_tools.egg-info/top_level.txt
|
|
15
|
+
test/test_data_extractor.py
|
|
16
|
+
test/test_dataset_specification.py
|
|
17
|
+
test/test_integrated.py
|
|
18
|
+
test/test_metadata.py
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
mario
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
from setuptools import setup
|
|
2
|
+
|
|
3
|
+
setup(
|
|
4
|
+
name='mario-pipeline-tools',
|
|
5
|
+
version='0.1',
|
|
6
|
+
packages=['mario'],
|
|
7
|
+
url='https://github.com/JiscDACT/mario',
|
|
8
|
+
license='all rights reserved',
|
|
9
|
+
author='scottbw',
|
|
10
|
+
author_email='scott.wilson@jisc.ac.uk',
|
|
11
|
+
description='Base classes and helpers for common data pipeline tasks',
|
|
12
|
+
long_description='A set of base classes and helpers for common data pipeline tasks, '
|
|
13
|
+
'whether using in local control scripts or in Airflow tasks.',
|
|
14
|
+
package_data={
|
|
15
|
+
# If any package contains *.ini files, include them
|
|
16
|
+
'': ['*.ini']
|
|
17
|
+
},
|
|
18
|
+
include_package_data=True,
|
|
19
|
+
install_requires=['pandas', 'tableauhyperapi', 'pantab', 'tableau-builder==0.18']
|
|
20
|
+
)
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import tempfile
|
|
3
|
+
|
|
4
|
+
from mario.data_extractor import DataExtractor, Configuration
|
|
5
|
+
from mario.dataset_specification import dataset_from_json
|
|
6
|
+
from mario.metadata import metadata_from_json
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def test_csv_to_csv():
|
|
10
|
+
dataset = dataset_from_json(os.path.join('test', 'dataset.json'))
|
|
11
|
+
metadata = metadata_from_json(os.path.join('test', 'metadata.json'))
|
|
12
|
+
configuration = Configuration(
|
|
13
|
+
file_path=os.path.join('test', 'orders.csv')
|
|
14
|
+
)
|
|
15
|
+
extractor = DataExtractor(
|
|
16
|
+
dataset_specification=dataset,
|
|
17
|
+
metadata=metadata,
|
|
18
|
+
configuration=configuration
|
|
19
|
+
)
|
|
20
|
+
extractor.validate_data()
|
|
21
|
+
with tempfile.NamedTemporaryFile(suffix='.csv') as file:
|
|
22
|
+
extractor.save_data_as_csv(file_path=file.name)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def test_csv_to_hyper():
|
|
26
|
+
dataset = dataset_from_json(os.path.join('test', 'dataset.json'))
|
|
27
|
+
metadata = metadata_from_json(os.path.join('test', 'metadata.json'))
|
|
28
|
+
configuration = Configuration(
|
|
29
|
+
file_path=os.path.join('test', 'orders.csv')
|
|
30
|
+
)
|
|
31
|
+
extractor = DataExtractor(
|
|
32
|
+
dataset_specification=dataset,
|
|
33
|
+
metadata=metadata,
|
|
34
|
+
configuration=configuration
|
|
35
|
+
)
|
|
36
|
+
with tempfile.NamedTemporaryFile(suffix='.hyper') as file:
|
|
37
|
+
extractor.save_data_as_hyper(file_path=file.name)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def test_hyper_to_csv():
|
|
41
|
+
dataset = dataset_from_json(os.path.join('test', 'dataset.json'))
|
|
42
|
+
metadata = metadata_from_json(os.path.join('test', 'metadata.json'))
|
|
43
|
+
configuration = Configuration(
|
|
44
|
+
file_path=os.path.join('test', 'orders.hyper')
|
|
45
|
+
)
|
|
46
|
+
extractor = DataExtractor(
|
|
47
|
+
dataset_specification=dataset,
|
|
48
|
+
metadata=metadata,
|
|
49
|
+
configuration=configuration
|
|
50
|
+
)
|
|
51
|
+
extractor.validate_data()
|
|
52
|
+
with tempfile.NamedTemporaryFile(suffix='.csv') as file:
|
|
53
|
+
extractor.save_data_as_csv(file_path=file.name)
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import os
|
|
2
|
+
|
|
3
|
+
from mario.dataset_specification import dataset_from_json
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def test_import_dataset():
|
|
7
|
+
specification_file = os.path.join('test', 'dataset.json')
|
|
8
|
+
specification = dataset_from_json(file_path=specification_file)
|
|
9
|
+
|
|
10
|
+
assert specification.collection == 'superstore'
|
|
11
|
+
assert len(specification.measures) == 4
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import os
|
|
2
|
+
|
|
3
|
+
from mario.data_extractor import Configuration, HyperFile
|
|
4
|
+
from mario.dataset_builder import DatasetBuilder, Format
|
|
5
|
+
from mario.dataset_specification import dataset_from_json
|
|
6
|
+
from mario.metadata import metadata_from_json
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def test_integration_tdsx():
|
|
10
|
+
dataset = dataset_from_json(os.path.join('test', 'dataset.json'))
|
|
11
|
+
metadata = metadata_from_json(os.path.join('test', 'metadata.json'))
|
|
12
|
+
configuration = Configuration(file_path=os.path.join('test', 'orders.hyper'))
|
|
13
|
+
extractor = HyperFile(configuration=configuration, dataset_specification=dataset, metadata=metadata)
|
|
14
|
+
builder = DatasetBuilder(dataset_specification=dataset, metadata=metadata, data=extractor)
|
|
15
|
+
path = os.path.join('output', dataset.collection, dataset.name + '.tdsx')
|
|
16
|
+
os.makedirs(os.path.join('output', dataset.collection), exist_ok=True)
|
|
17
|
+
builder.build(file_path=path, output_format=Format.TABLEAU_PACKAGED_DATASOURCE)
|
|
18
|
+
|
|
19
|
+
def test_integration_csv():
|
|
20
|
+
dataset = dataset_from_json(os.path.join('test', 'dataset.json'))
|
|
21
|
+
metadata = metadata_from_json(os.path.join('test', 'metadata.json'))
|
|
22
|
+
configuration = Configuration(file_path=os.path.join('test', 'orders.hyper'))
|
|
23
|
+
extractor = HyperFile(configuration=configuration, dataset_specification=dataset, metadata=metadata)
|
|
24
|
+
builder = DatasetBuilder(dataset_specification=dataset, metadata=metadata, data=extractor)
|
|
25
|
+
path = os.path.join('output', dataset.collection, dataset.name + '.csv')
|
|
26
|
+
os.makedirs(os.path.join('output', dataset.collection), exist_ok=True)
|
|
27
|
+
builder.build(file_path=path, output_format=Format.CSV)
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import tempfile
|
|
3
|
+
|
|
4
|
+
from mario.metadata import metadata_from_json
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def test_load_metadata():
|
|
8
|
+
metadata_file = os.path.join('test', 'metadata.json')
|
|
9
|
+
metadata = metadata_from_json(file_path=metadata_file)
|
|
10
|
+
assert metadata.get_metadata('Ship Mode') is not None
|
|
11
|
+
assert metadata.get_metadata('Ship Mode').description is not None
|
|
12
|
+
assert metadata.get_metadata('Ship Mode').get_property('groups') == ['Shipping']
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def test_save_metadata():
|
|
16
|
+
metadata_file = os.path.join('test', 'metadata.json')
|
|
17
|
+
metadata = metadata_from_json(file_path=metadata_file)
|
|
18
|
+
with tempfile.NamedTemporaryFile() as file:
|
|
19
|
+
metadata.save(file_path=file.name)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def test_load_tdsa():
|
|
23
|
+
metadata_file = os.path.join('test', 'tdsa.json')
|
|
24
|
+
metadata = metadata_from_json(file_path=metadata_file)
|