datahub-api-connector 1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,19 @@
1
+ Copyright (c) 2025 Verity
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,78 @@
1
+ Metadata-Version: 2.4
2
+ Name: datahub-api-connector
3
+ Version: 1.0
4
+ Summary: Package to help connect to Verity's Data Hub API
5
+ Author-email: Verity <support@opinum.com>
6
+ Maintainer-email: Verity <support@opinum.com>
7
+ License-Expression: MIT
8
+ Project-URL: Homepage, https://github.com/opinum/datahub-api-connector
9
+ Project-URL: Bug Tracker, https://github.com/opinum/datahub-api-connector/issues
10
+ Keywords: datahub,Verity,Opinum,API,Keycloak
11
+ Classifier: Development Status :: 5 - Production/Stable
12
+ Classifier: Programming Language :: Python
13
+ Requires-Python: >=3.10
14
+ Description-Content-Type: text/markdown
15
+ License-File: LICENSE
16
+ Requires-Dist: requests
17
+ Requires-Dist: requests_oauthlib
18
+ Requires-Dist: oauthlib
19
+ Dynamic: license-file
20
+
21
+ This package simplifies the calls to Data Hub's API.
22
+
23
+ No magic. You need to follow the [Data Hub Swagger Documentation](https://api.opinum.com) for correct formatting of your requests
24
+
25
+ Be aware that this is a new package, certainly missing a lot of features. Feel free to contribute.
26
+
27
+ You first need to create an instance of the ApiConnector class with following parameters:
28
+
29
+ *ATTENTION:* this package can only be used from Data Hub version 7.0 (1 July 2025) onward, as it uses the authentication to the the new technology (Keycloak).
30
+ For previous versions, the opinum-api-connector package can be used (https://github.com/opinum/opinum-api-connector) instead.
31
+
32
+ > _environment_
33
+ > > a dictionary of environment variables
34
+ > >
35
+ > > if `None`, ApiConnector uses your environment variables (_os.environ_)
36
+ > >
37
+ > > Mandatory environment variables are:
38
+ > >
39
+ > > * _DATAHUB_USERNAME_: the Datahub user. <br>
40
+ > > TAKE CARE: if this user has access to multiple tenants and if you do not specify a tenant id,
41
+ > > ApiConnector will use the last tenant used.
42
+ > > * _DATAHUB_PASSWORD_: the password for the user
43
+ > > * _DATAHUB_CLIENT_ID_: the client id for accessing the API
44
+ > > * _DATAHUB_CLIENT_SECRET_ the corresponding secret
45
+ > >
46
+ > > Optional environment variables are:
47
+ > >
48
+ > > * _DATAHUB_API_URL_: another API URL than the Europe SaaS one (https://api.opinum.com)
49
+ > > * _DATAHUB_AUTH_URL_: another authentication URL than the Europe SaaS one (https://auth.opinum.com)
50
+ > > * _DATAHUB_PUSH_URL_: another push URL than the Europe SaaS one (https://push.opinum.com)
51
+ > > * _DATAHUB_SCOPE_: the scope of you session (default: "_datahub-api_")<br>
52
+ > > if you want to push data, the scope should be "_datahub-api push-data_"
53
+
54
+ > _account_id_
55
+ > > one of the tenant ids available for the Datahub user (default: `None`)
56
+
57
+ > _retries_when_connection_failure_
58
+ > > number of extra attempts when no 200 or 204 return code (default: 0, maximum: 5)
59
+
60
+ Once you have your ApiConnector instance, you may use the class methods
61
+
62
+ * get
63
+ * post
64
+ * patch
65
+ * put
66
+ * delete
67
+ * send_file_to_storage
68
+
69
+ All keyword arguments will be converted to path parameters in the API call with one important exception,
70
+ the _data_ keyword referring to the body of your call.
71
+
72
+ There are two other class methods for data pushing because we have another API for this
73
+
74
+ * push_data
75
+ * push_dataframe_data
76
+
77
+ There is a little bit of magic with the method multi_thread_request_on_path that splits a list of parameters
78
+ Allowing to make parallel calls.
@@ -0,0 +1,58 @@
1
+ This package simplifies the calls to Data Hub's API.
2
+
3
+ No magic. You need to follow the [Data Hub Swagger Documentation](https://api.opinum.com) for correct formatting of your requests
4
+
5
+ Be aware that this is a new package, certainly missing a lot of features. Feel free to contribute.
6
+
7
+ You first need to create an instance of the ApiConnector class with following parameters:
8
+
9
+ *ATTENTION:* this package can only be used from Data Hub version 7.0 (1 July 2025) onward, as it uses the authentication to the the new technology (Keycloak).
10
+ For previous versions, the opinum-api-connector package can be used (https://github.com/opinum/opinum-api-connector) instead.
11
+
12
+ > _environment_
13
+ > > a dictionary of environment variables
14
+ > >
15
+ > > if `None`, ApiConnector uses your environment variables (_os.environ_)
16
+ > >
17
+ > > Mandatory environment variables are:
18
+ > >
19
+ > > * _DATAHUB_USERNAME_: the Datahub user. <br>
20
+ > > TAKE CARE: if this user has access to multiple tenants and if you do not specify a tenant id,
21
+ > > ApiConnector will use the last tenant used.
22
+ > > * _DATAHUB_PASSWORD_: the password for the user
23
+ > > * _DATAHUB_CLIENT_ID_: the client id for accessing the API
24
+ > > * _DATAHUB_CLIENT_SECRET_ the corresponding secret
25
+ > >
26
+ > > Optional environment variables are:
27
+ > >
28
+ > > * _DATAHUB_API_URL_: another API URL than the Europe SaaS one (https://api.opinum.com)
29
+ > > * _DATAHUB_AUTH_URL_: another authentication URL than the Europe SaaS one (https://auth.opinum.com)
30
+ > > * _DATAHUB_PUSH_URL_: another push URL than the Europe SaaS one (https://push.opinum.com)
31
+ > > * _DATAHUB_SCOPE_: the scope of you session (default: "_datahub-api_")<br>
32
+ > > if you want to push data, the scope should be "_datahub-api push-data_"
33
+
34
+ > _account_id_
35
+ > > one of the tenant ids available for the Datahub user (default: `None`)
36
+
37
+ > _retries_when_connection_failure_
38
+ > > number of extra attempts when no 200 or 204 return code (default: 0, maximum: 5)
39
+
40
+ Once you have your ApiConnector instance, you may use the class methods
41
+
42
+ * get
43
+ * post
44
+ * patch
45
+ * put
46
+ * delete
47
+ * send_file_to_storage
48
+
49
+ All keyword arguments will be converted to path parameters in the API call with one important exception,
50
+ the _data_ keyword referring to the body of your call.
51
+
52
+ There are two other class methods for data pushing because we have another API for this
53
+
54
+ * push_data
55
+ * push_dataframe_data
56
+
57
+ There is a little bit of magic with the method multi_thread_request_on_path that splits a list of parameters
58
+ Allowing to make parallel calls.
@@ -0,0 +1,32 @@
1
+ [build-system]
2
+ requires = ['setuptools>= 77.0.3']
3
+ build-backend = 'setuptools.build_meta'
4
+
5
+ [project]
6
+ name = "datahub-api-connector"
7
+ version = "1.0"
8
+ dependencies = [
9
+ "requests",
10
+ "requests_oauthlib",
11
+ "oauthlib"
12
+ ]
13
+ requires-python = ">=3.10"
14
+ authors = [
15
+ {name = "Verity", email = "support@opinum.com"}
16
+ ]
17
+ maintainers = [
18
+ {name = "Verity", email = "support@opinum.com"}
19
+ ]
20
+ description = "Package to help connect to Verity's Data Hub API"
21
+ readme = "README.md"
22
+ license = "MIT"
23
+ license-files = ["LICENSE"]
24
+ keywords = ["datahub", "Verity", "Opinum", "API", "Keycloak"]
25
+ classifiers = [
26
+ "Development Status :: 5 - Production/Stable",
27
+ "Programming Language :: Python"
28
+ ]
29
+
30
+ [project.urls]
31
+ Homepage = "https://github.com/opinum/datahub-api-connector"
32
+ "Bug Tracker" = "https://github.com/opinum/datahub-api-connector/issues"
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
File without changes
@@ -0,0 +1,248 @@
1
+ import os
2
+ from oauthlib.oauth2 import LegacyApplicationClient
3
+ from requests_oauthlib import OAuth2Session
4
+ import json
5
+ import requests
6
+ import datetime as dt
7
+ import logging
8
+ from time import sleep
9
+ import concurrent.futures
10
+
11
+ DEFAULT_API_URL = 'https://api.opinum.com'
12
+ DEFAULT_AUTH_URL = 'https://auth.opinum.com'
13
+ DEFAULT_SCOPE = 'datahub-api'
14
+ DEFAULT_PUSH_URL = 'https://push.opinum.com'
15
+
16
+
17
+ class ApiConnector:
18
+ """
19
+ A class for connection to Data Hub API
20
+
21
+ :param environment: a dictionary with all environment variables
22
+ :param account_id: the account id to use (for users having access to multiple tenants)
23
+ :param retries_when_connection_failure: allows to make several attempts to have a successful query (connection issues can happen)
24
+ """
25
+
26
+ time_limit = 3 * 60 # Three minutes
27
+
28
+ MAX_RETRIES_WHEN_CONNECTION_FAILURE = 5
29
+
30
+ def __init__(self,
31
+ environment=None,
32
+ account_id=None,
33
+ retries_when_connection_failure=0,
34
+ seconds_between_retries=5):
35
+ self.environment = os.environ if environment is None else environment
36
+ self.api_url = self.environment.get('DATAHUB_API_URL', self.environment.get('OPINUM_API_URL', DEFAULT_API_URL))
37
+ self.auth_url = f"{self.environment.get('DATAHUB_AUTH_URL', self.environment.get('OPINUM_AUTH_URL', DEFAULT_AUTH_URL))}/realms/opinum/protocol/openid-connect/token"
38
+ self.push_url = f"{self.environment.get('DATAHUB_PUSH_URL', self.environment.get('OPINUM_PUSH_URL', DEFAULT_PUSH_URL))}/api/data/"
39
+ self.scope = self.environment.get('DATAHUB_SCOPE', self.environment.get('OPINUM_SCOPE', DEFAULT_SCOPE))
40
+ self.username = self.environment.get('DATAHUB_USERNAME', self.environment.get('OPINUM_USERNAME'))
41
+ self.password = self.environment.get('DATAHUB_PASSWORD', self.environment.get('OPINUM_PASSWORD'))
42
+ self.client_id = self.environment.get('DATAHUB_CLIENT_ID', self.environment.get('OPINUM_CLIENT_ID'))
43
+ self.client_secret = self.environment.get('DATAHUB_CLIENT_SECRET', self.environment.get('OPINUM_SECRET'))
44
+ self.account_id = account_id
45
+ self.creation_time = None
46
+ self.token = None
47
+ self._set_token()
48
+ self.max_call_attempts = 1 + min(retries_when_connection_failure, self.MAX_RETRIES_WHEN_CONNECTION_FAILURE)
49
+ self.seconds_between_retries = seconds_between_retries
50
+
51
+ def _set_token(self):
52
+ oauth = OAuth2Session(client=LegacyApplicationClient(client_id=self.client_id))
53
+ args = {
54
+ 'token_url': f"{self.auth_url}",
55
+ 'scope': self.scope,
56
+ 'username': self.username,
57
+ 'password': self.password,
58
+ 'client_id': self.client_id,
59
+ 'client_secret': self.client_secret,
60
+ 'auth': None
61
+ }
62
+ if self.account_id is not None:
63
+ args['account'] = self.account_id
64
+ self.token = oauth.fetch_token(**args)
65
+ self.creation_time = dt.datetime.now()
66
+
67
+ @property
68
+ def _headers(self):
69
+ if (dt.datetime.now() - self.creation_time).total_seconds() > self.time_limit:
70
+ self._set_token()
71
+ return {"Content-Type": "application/json",
72
+ "Authorization": f"Bearer {self.token['access_token']}"}
73
+
74
+ def _process_request(self, method, url, data, **kwargs):
75
+ attempts = 0
76
+ error = Exception('Unknown exception')
77
+ while attempts < self.max_call_attempts:
78
+ try:
79
+ if data is not None:
80
+ data = json.dumps(data)
81
+ params = dict()
82
+ for k, v in kwargs.items():
83
+ if isinstance(v, dt.datetime):
84
+ v = v.strftime('%Y-%m-%dT%H:%M:%S')
85
+ if k == 'date_from':
86
+ k = 'from'
87
+ params[k] = v
88
+
89
+ response = method(url, data=data, params=params, headers=self._headers)
90
+ response.raise_for_status()
91
+ return response
92
+ except (requests.exceptions.ConnectionError, AssertionError) as e:
93
+ error = e
94
+ attempts += 1
95
+ logging.warning(f"Failure {attempts}")
96
+ sleep(self.seconds_between_retries)
97
+ if attempts == self.max_call_attempts:
98
+ logging.error(error)
99
+ raise error
100
+
101
+ def get(self, endpoint, data=None, **kwargs):
102
+ """
103
+ Method for data query in the API
104
+
105
+ :param endpoint: the Data Hub API endpoint
106
+ :param data: body of the request. Should always be None for a get.
107
+ :param kwargs: dictionary of API call parameters
108
+ :return: the http request response
109
+ """
110
+
111
+ return self._process_request(requests.get,
112
+ f"{self.api_url}/{endpoint}",
113
+ data=data,
114
+ **kwargs)
115
+
116
+ def post(self, endpoint, data=None, **kwargs):
117
+ """
118
+ Method for data creation in the API
119
+
120
+ :param endpoint: the Data Hub API endpoint
121
+ :param data: body of the request
122
+ :param kwargs: dictionary of API call parameters
123
+ :return: the http request response
124
+ """
125
+ return self._process_request(requests.post,
126
+ f"{self.api_url}/{endpoint}",
127
+ data=data,
128
+ **kwargs)
129
+
130
+ def patch(self, endpoint, data=None, **kwargs):
131
+ """
132
+ Method for data patching in the API
133
+
134
+ :param endpoint: the Data Hub API endpoint
135
+ :param data: body of the request
136
+ :param kwargs: dictionary of API call parameters; see https://jsonpatch.com/
137
+ :return: the http request response
138
+ """
139
+ return self._process_request(requests.patch,
140
+ f"{self.api_url}/{endpoint}",
141
+ data=data,
142
+ **kwargs)
143
+
144
+ def put(self, endpoint, data=None, **kwargs):
145
+ """
146
+ Method for data update in the API
147
+
148
+ :param endpoint: the Data Hub API endpoint
149
+ :param data: body of the request
150
+ :param kwargs: dictionary of API call parameters
151
+ :return: the http request response
152
+ """
153
+ return self._process_request(requests.put,
154
+ f"{self.api_url}/{endpoint}",
155
+ data=data,
156
+ **kwargs)
157
+
158
+ def delete(self, endpoint, data=None, **kwargs):
159
+ """
160
+ Method for data deletion in the API
161
+
162
+ :param endpoint: the Data Hub API endpoint
163
+ :param data: body of the request
164
+ :param kwargs: dictionary of API call parameters
165
+ :return: the http request response
166
+ """
167
+ return self._process_request(requests.delete,
168
+ f"{self.api_url}/{endpoint}",
169
+ data=data,
170
+ **kwargs)
171
+
172
+ def push_data(self, body, operation_id: str=None):
173
+ """
174
+ Method for data push in the API
175
+
176
+ :param body: see https://docs.opinum.com/articles/push-formats/standard-format.html
177
+ :param operation_id: a string representing the operationId of the push; see https://docs.opinum.com/articles/push-formats/standard-format.html#ask-for-a-webhook-notification
178
+ :return: the http request response
179
+ """
180
+ return self._process_request(requests.post,
181
+ self.push_url+("?"+str(operation_id) if operation_id is not None else ""),
182
+ body)
183
+
184
+ def push_dataframe_data(self, df, **kwargs):
185
+ """
186
+ Method for data push in the API using a pandas DataFrame
187
+
188
+ :param df: a pandas dataframe with dates in ISO format in 'date' column and values in 'value' column
189
+ :param kwargs: dictionary of API call parameters, allowing to identify the target variable (see https://docs.opinum.com/articles/push-formats/standard-format.html)
190
+ :return: the http request response
191
+ """
192
+ kwargs['data'] = df.to_dict('records')
193
+ return self.push_data([kwargs])
194
+
195
+ def send_file_to_storage(self, filename, file_io, mime_type):
196
+ """
197
+ Method for sending a file to the storage
198
+
199
+ :param filename: The file name you want to give in the storage
200
+ :param file_io: a Bytes IO or a file opened in binary
201
+ :param mime_type: The file MIME Type
202
+ :return: the http request response
203
+ """
204
+ return requests.post(f"{self.api_url}/storage?filename={filename}",
205
+ files={'data': (filename, file_io, mime_type)},
206
+ headers={"Authorization": self._headers['Authorization']})
207
+
208
+
209
+ def default_response_callback(response):
210
+ return response
211
+
212
+
213
+ def multi_thread_request_on_path(method, endpoint,
214
+ split_parameter, max_parameter_entities, max_futures, workers=16,
215
+ response_callback=default_response_callback,
216
+ **kwargs):
217
+ """
218
+
219
+ :param method: The method to use. Most used is api_connector.get where api_connector is an instance of ApiConnector
220
+ :param endpoint: The endpoint
221
+ :param split_parameter: The parameter having a list as input that we will split in smaller calls
222
+ :param max_parameter_entities: The maximum number of parameters in each separate call. Mostly driven by the limit in length of the url on a http get
223
+ :param max_futures: Preparing at once all threads is not optimal. We better loop on several groups of calls
224
+ :param workers: The number of parallel threads. default: 16
225
+ :param response_callback: a method with a requests response as input returning what you expect. default: a method returning the response as is
226
+ :param kwargs: the list of http parameters
227
+ :return: a generator returning the results of your response_callback
228
+ """
229
+ futures = list()
230
+ entities = kwargs[split_parameter]
231
+ future_run_entities = max_parameter_entities * max_futures
232
+ with concurrent.futures.ProcessPoolExecutor(max_workers=workers) as executor:
233
+ for block in [entities[i: i + future_run_entities] for i in range(0, len(entities), future_run_entities)]:
234
+ for sub_block in [block[i: i + max_futures] for i in range(0, len(block), max_futures)]:
235
+ run_args = kwargs.copy()
236
+ run_args[split_parameter] = sub_block
237
+ futures.append(executor.submit(method, endpoint, **run_args))
238
+ while True:
239
+ all_finished = True
240
+ for i, future in enumerate(futures):
241
+ if future.done():
242
+ yield response_callback(future.result())
243
+ futures.pop(i)
244
+ else:
245
+ all_finished = False
246
+ if all_finished:
247
+ break
248
+
@@ -0,0 +1,260 @@
1
+ import logging
2
+ from copy import deepcopy
3
+
4
+ from datahub_api_connector import ApiConnector
5
+
6
+ logging.basicConfig(level=logging.INFO)
7
+
8
+
9
+ def default_source_callback(source, target_site):
10
+ return source
11
+
12
+
13
+ def default_get_allowed_sources(site, site_content_duplicator):
14
+ return None
15
+
16
+
17
+ def default_get_excluded_sources(site, site_content_duplicator):
18
+ return None
19
+
20
+
21
+ def default_get_allowed_variables(site, source, site_content_duplicator):
22
+ return None
23
+
24
+
25
+ def default_get_excluded_variables(site, source, site_content_duplicator):
26
+ return None
27
+
28
+
29
+ def do_not_process_source(source_key, allowed_sources, excluded_sources):
30
+ not_allowed = allowed_sources is not None and source_key not in allowed_sources
31
+ excluded = excluded_sources is not None and source_key in excluded_sources
32
+ return not_allowed or excluded
33
+
34
+
35
+ def do_not_process_variable(mapping_config, allowed_variables, excluded_variables):
36
+ not_allowed = allowed_variables is not None and mapping_config not in allowed_variables
37
+ excluded = excluded_variables is not None and mapping_config in excluded_variables
38
+ return not_allowed or excluded
39
+
40
+
41
+ class SiteContentDuplicator:
42
+ """
43
+ SiteContentDuplicator allows make a full copy of a site
44
+
45
+ This implies a copy of all sources in the site with their variables (raw and calculated)
46
+ """
47
+ def __init__(self, target_site_ids, template_site_id, source_key_field, account_id=None,
48
+ source_callback=default_source_callback,
49
+ get_allowed_sources=default_get_allowed_sources,
50
+ get_excluded_sources=default_get_excluded_sources,
51
+ get_allowed_variables=default_get_allowed_variables,
52
+ get_excluded_variables=default_get_excluded_variables,
53
+ ignore_missing_calculated_inputs=True,
54
+ update_sources=False,
55
+ update_variables=False):
56
+ """
57
+
58
+ :param target_site_ids: a list of site ids that will host the new sources
59
+ :param template_site_id: the site id that we copy
60
+ :param source_key_field: allows to identify the sources: 'name', 'serialNumber', 'meternumber' or 'eanNumber'
61
+ :param account_id: the tenant id
62
+ :param source_callback: a method that transforms the source received input. Manipulating the source_key_field is allowed.
63
+ :param get_allowed_sources: a method that returns the list of allowed source (via their key) for a given site. None means all sources are allowed.
64
+ :param get_excluded_sources: a method that returns the list of excluded source (via their key) for a given site. None means no exclusion.
65
+ :param get_allowed_variables: a method that returns the list of allowed variables (via their mapping config) for a given site/source. None means all variables are allowed.
66
+ :param get_excluded_variables: a method that returns the list of allowed variables (via their mapping config) for a given site/source. None means all variables are allowed.
67
+ :param ignore_missing_calculated_inputs: tell what to do with calculated variables inputs without associated source or variable in the target site
68
+ :param update: to force updates
69
+ """
70
+ logging.info('Start Site Duplicator')
71
+ self.target_site_ids = target_site_ids
72
+ self.template_site_id = template_site_id
73
+ self.source_key_field = source_key_field
74
+ self.source_callback = source_callback
75
+ self.get_allowed_sources = get_allowed_sources
76
+ self.get_excluded_sources = get_excluded_sources
77
+ self.get_allowed_variables = get_allowed_variables
78
+ self.get_excluded_variables = get_excluded_variables
79
+ self.ignore_missing_calculated_inputs = ignore_missing_calculated_inputs
80
+ self.update_sources = update_sources
81
+ self.update_variables = update_variables
82
+ self.api_connector = ApiConnector(account_id=account_id)
83
+ self.template_sources = dict()
84
+ self.template_vars = dict()
85
+ self.template_var_id_infos = dict()
86
+ for source in self.api_connector.get('sources',
87
+ siteId=self.template_site_id,
88
+ displayLevel='Site').json():
89
+ logging.info(f"Getting info for source {source[self.source_key_field]}")
90
+ self.template_sources[source['id']] = source
91
+ self.template_vars[source[self.source_key_field]] = dict()
92
+ for variable in self.api_connector.get('variables',
93
+ sourceId=source['id'],
94
+ displayLevel='Verbose').json():
95
+ if 'mappingConfig' not in variable:
96
+ logging.warning(f"Variable named {variable['name']} has no mapping config. Ignored")
97
+ continue
98
+ logging.info(f"\tGetting info for variable {variable['mappingConfig']}")
99
+ self.template_vars[source[self.source_key_field]][variable['mappingConfig']] = variable
100
+ self.template_var_id_infos[variable['id']] = {
101
+ 'source': source,
102
+ 'variable': variable
103
+ }
104
+
105
+ def process_site(self, site):
106
+ logging.info(f"Processing site {site['name']}")
107
+ existing_sources = {s[self.source_key_field]: s for s in self.api_connector.get('sources',
108
+ siteId=site['id'],
109
+ displayLevel='Site').json()}
110
+ source_key_mappings = dict()
111
+ allowed_sources = self.get_allowed_sources(site, self)
112
+ excluded_sources = self.get_excluded_sources(site, self)
113
+ for source in self.template_sources.values():
114
+ # key for new source can be different with template
115
+ new_source = self.source_callback(deepcopy(source), site)
116
+ source_key = new_source[self.source_key_field]
117
+ if do_not_process_source(source_key, allowed_sources, excluded_sources):
118
+ logging.info(f"Source {source_key} is excluded from processing")
119
+ continue
120
+ logging.info(f"Checking source {source_key}")
121
+ new_source.pop('siteName')
122
+ new_source['siteId'] = site['id']
123
+ if source_key not in existing_sources:
124
+ logging.info("\tCreating source")
125
+ new_source.pop('id')
126
+ existing_sources[source_key] = self.api_connector.post('sources', data=new_source).json()
127
+ elif self.update_sources:
128
+ logging.info("\tUpdating source")
129
+ new_source['id'] = existing_sources[source_key]['id']
130
+ self.api_connector.put(f"sources/{new_source['id']}", data=new_source)
131
+
132
+ source_key_mappings[source[self.source_key_field]] = source_key
133
+
134
+ # Second run now that all sources exist
135
+ calculated_variables_templates = dict()
136
+ existing_variables = dict()
137
+ for source_key, source in existing_sources.items():
138
+ if source[self.source_key_field] not in self.template_vars:
139
+ logging.warning(f"Source {source_key} is not existing in template. No variables to handle")
140
+ continue
141
+ if do_not_process_source(source_key, allowed_sources, excluded_sources):
142
+ logging.info(f"Source {source_key} is excluded from processing. No variables to handle")
143
+ continue
144
+ logging.info(f"Checking variables for source {source_key}")
145
+ allowed_variables = self.get_allowed_variables(site, source, self)
146
+ excluded_variables = self.get_excluded_variables(site, source, self)
147
+ existing_variables[source_key] = {v['mappingConfig']: v for v in self.api_connector.get('variables',
148
+ sourceId=source['id'],
149
+ displayLevel='Verbose').json()}
150
+ for mapping, variable in self.template_vars[source[self.source_key_field]].items():
151
+ if do_not_process_variable(mapping, allowed_variables, excluded_variables):
152
+ logging.info(f"Variable {mapping} is excluded from processing")
153
+ continue
154
+ new_var = deepcopy(variable)
155
+ new_var['sourceId'] = source['id']
156
+ if mapping in existing_variables[source_key]:
157
+ if self.update_variables:
158
+ new_var_id = existing_variables[source_key][mapping]['id']
159
+ new_var['id'] = new_var_id
160
+ if 'calculated' in new_var:
161
+ calculated_variables_templates.setdefault(source_key, list()).append(variable)
162
+ new_var['calculated']['calculatedVariableFormulas'] = [{}]
163
+ logging.info(f"\tUpdating variable {mapping}")
164
+ self.api_connector.put(f"sources/{source['id']}/variables/{new_var_id}", data=new_var)
165
+ else:
166
+ if 'calculated' in new_var:
167
+ # Keep another copy for later
168
+ calculated_variables_templates.setdefault(source_key, list()).append(variable)
169
+ new_var['calculated']['calculatedVariableFormulas'] = [{}]
170
+ new_var.pop('id')
171
+ logging.info(f"\tCreating variable {mapping}")
172
+ existing_variables[source_key][mapping] = self.api_connector.post(f"variables/source/{source['id']}",
173
+ data=new_var).json()
174
+
175
+ # Third run now that all variables exist (without calculations possibly)
176
+ for source_key, variables in calculated_variables_templates.items():
177
+ source = existing_sources[source_key]
178
+ logging.info(f"Finalising calculated variables for source {source_key}")
179
+ for new_var in variables:
180
+ mapping = new_var['mappingConfig']
181
+ logging.info(f"\tUpdating variable {mapping}")
182
+ new_var_id = existing_variables[source_key][mapping]['id']
183
+ new_var['id'] = new_var_id
184
+ new_var['sourceId'] = source['id']
185
+ for formula in new_var['calculated']['calculatedVariableFormulas']:
186
+ new_variables_list = list()
187
+ for sub_var in formula['variables']:
188
+ if sub_var['siteId'] != self.template_site_id:
189
+ new_variables_list.append(deepcopy(sub_var))
190
+ continue
191
+ if sub_var['variableId'] not in self.template_var_id_infos:
192
+ message = (f"Source {sub_source_info['source'][self.source_key_field]} has an input variable"
193
+ f" without mapping config.")
194
+ if self.ignore_missing_calculated_inputs:
195
+ logging.info(f"{message} Dropped from the list of input variables")
196
+ else:
197
+ logging.info(f"{message} We keep the template variable")
198
+ new_variables_list.append(deepcopy(sub_var))
199
+ continue
200
+ sub_source_info = self.template_var_id_infos[sub_var['variableId']]
201
+ if sub_source_info['source'][self.source_key_field] not in source_key_mappings:
202
+ message = (f"Source {sub_source_info['source'][self.source_key_field]} has an input variable"
203
+ f" but is not covered by this run.")
204
+ if self.ignore_missing_calculated_inputs:
205
+ logging.info(f"{message} Dropped from the list of input variables")
206
+ else:
207
+ logging.info(f"{message} We keep the template variable")
208
+ new_variables_list.append(deepcopy(sub_var))
209
+ continue
210
+ sub_source_key = source_key_mappings[sub_source_info['source'][self.source_key_field]]
211
+ sub_mapping = sub_source_info['variable']['mappingConfig']
212
+ if sub_mapping not in existing_variables[sub_source_key]:
213
+ message = (f"Variable {sub_mapping} from source {sub_source_key} is an input variable"
214
+ f" but is not covered by this run.")
215
+ if self.ignore_missing_calculated_inputs:
216
+ logging.info(f"{message} Dropped from the list of input variables")
217
+ else:
218
+ logging.info(f"{message} We keep the template variable")
219
+ new_variables_list.append(deepcopy(sub_var))
220
+ continue
221
+ sub_var['siteId'] = site['id']
222
+ sub_var['sourceId'] = existing_sources[sub_source_key]['id']
223
+ sub_var['variableId'] = existing_variables[sub_source_key][sub_mapping]['id']
224
+ new_variables_list.append(sub_var)
225
+ formula['variables'] = new_variables_list
226
+ new_entities_list = list()
227
+ for form_value in formula['entities']:
228
+ if form_value['siteId'] != self.template_site_id:
229
+ new_entities_list.append(deepcopy(form_value))
230
+ continue
231
+ new_form_value = deepcopy(form_value)
232
+ if new_form_value['entityType'] == 1: # Site form
233
+ new_form_value['siteId'] = site['id']
234
+ new_form_value['entityId'] = site['id']
235
+ new_entities_list.append(new_form_value)
236
+ continue
237
+ sub_source = self.template_sources[form_value['sourceId']]
238
+ if sub_source[self.source_key_field] not in source_key_mappings:
239
+ message = (f"Source {sub_source_info['source'][self.source_key_field]} has an input form field"
240
+ f" but is not covered by this run.")
241
+ if self.ignore_missing_calculated_inputs:
242
+ logging.info(f"{message} Dropped from the list of input form fields")
243
+ else:
244
+ logging.info(f"{message} We keep the template form field")
245
+ new_entities_list.append(new_form_value)
246
+ continue
247
+ sub_source_key = source_key_mappings[sub_source_info['source'][self.source_key_field]]
248
+ new_form_value['siteId'] = site['id']
249
+ new_form_value['sourceId'] = existing_sources[sub_source_key]['id']
250
+ new_form_value['entityId'] = existing_sources[sub_source_key]['id']
251
+ new_entities_list.append(new_form_value)
252
+ formula['entities'] = new_entities_list
253
+ self.api_connector.put(f"sources/{source['id']}/variables/{new_var_id}", data=new_var)
254
+ existing_variables[mapping] = new_var
255
+
256
+ def run(self):
257
+ for site_id in self.target_site_ids:
258
+ site = self.api_connector.get('sites', siteIds=[site_id], displayLevel='VerboseSite').json()[0]
259
+ self.process_site(site)
260
+
@@ -0,0 +1,78 @@
1
+ Metadata-Version: 2.4
2
+ Name: datahub-api-connector
3
+ Version: 1.0
4
+ Summary: Package to help connect to Verity's Data Hub API
5
+ Author-email: Verity <support@opinum.com>
6
+ Maintainer-email: Verity <support@opinum.com>
7
+ License-Expression: MIT
8
+ Project-URL: Homepage, https://github.com/opinum/datahub-api-connector
9
+ Project-URL: Bug Tracker, https://github.com/opinum/datahub-api-connector/issues
10
+ Keywords: datahub,Verity,Opinum,API,Keycloak
11
+ Classifier: Development Status :: 5 - Production/Stable
12
+ Classifier: Programming Language :: Python
13
+ Requires-Python: >=3.10
14
+ Description-Content-Type: text/markdown
15
+ License-File: LICENSE
16
+ Requires-Dist: requests
17
+ Requires-Dist: requests_oauthlib
18
+ Requires-Dist: oauthlib
19
+ Dynamic: license-file
20
+
21
+ This package simplifies the calls to Data Hub's API.
22
+
23
+ No magic. You need to follow the [Data Hub Swagger Documentation](https://api.opinum.com) for correct formatting of your requests
24
+
25
+ Be aware that this is a new package, certainly missing a lot of features. Feel free to contribute.
26
+
27
+ You first need to create an instance of the ApiConnector class with following parameters:
28
+
29
+ *ATTENTION:* this package can only be used from Data Hub version 7.0 (1 July 2025) onward, as it uses the authentication to the the new technology (Keycloak).
30
+ For previous versions, the opinum-api-connector package can be used (https://github.com/opinum/opinum-api-connector) instead.
31
+
32
+ > _environment_
33
+ > > a dictionary of environment variables
34
+ > >
35
+ > > if `None`, ApiConnector uses your environment variables (_os.environ_)
36
+ > >
37
+ > > Mandatory environment variables are:
38
+ > >
39
+ > > * _DATAHUB_USERNAME_: the Datahub user. <br>
40
+ > > TAKE CARE: if this user has access to multiple tenants and if you do not specify a tenant id,
41
+ > > ApiConnector will use the last tenant used.
42
+ > > * _DATAHUB_PASSWORD_: the password for the user
43
+ > > * _DATAHUB_CLIENT_ID_: the client id for accessing the API
44
+ > > * _DATAHUB_CLIENT_SECRET_ the corresponding secret
45
+ > >
46
+ > > Optional environment variables are:
47
+ > >
48
+ > > * _DATAHUB_API_URL_: another API URL than the Europe SaaS one (https://api.opinum.com)
49
+ > > * _DATAHUB_AUTH_URL_: another authentication URL than the Europe SaaS one (https://auth.opinum.com)
50
+ > > * _DATAHUB_PUSH_URL_: another push URL than the Europe SaaS one (https://push.opinum.com)
51
+ > > * _DATAHUB_SCOPE_: the scope of you session (default: "_datahub-api_")<br>
52
+ > > if you want to push data, the scope should be "_datahub-api push-data_"
53
+
54
+ > _account_id_
55
+ > > one of the tenant ids available for the Datahub user (default: `None`)
56
+
57
+ > _retries_when_connection_failure_
58
+ > > number of extra attempts when no 200 or 204 return code (default: 0, maximum: 5)
59
+
60
+ Once you have your ApiConnector instance, you may use the class methods
61
+
62
+ * get
63
+ * post
64
+ * patch
65
+ * put
66
+ * delete
67
+ * send_file_to_storage
68
+
69
+ All keyword arguments will be converted to path parameters in the API call with one important exception,
70
+ the _data_ keyword referring to the body of your call.
71
+
72
+ There are two other class methods for data pushing because we have another API for this
73
+
74
+ * push_data
75
+ * push_dataframe_data
76
+
77
+ There is a little bit of magic with the method multi_thread_request_on_path that splits a list of parameters
78
+ Allowing to make parallel calls.
@@ -0,0 +1,12 @@
1
+ LICENSE
2
+ README.md
3
+ pyproject.toml
4
+ src/__init__.py
5
+ src/datahub_api_connector/__init__.py
6
+ src/datahub_api_connector.egg-info/PKG-INFO
7
+ src/datahub_api_connector.egg-info/SOURCES.txt
8
+ src/datahub_api_connector.egg-info/dependency_links.txt
9
+ src/datahub_api_connector.egg-info/requires.txt
10
+ src/datahub_api_connector.egg-info/top_level.txt
11
+ src/datahub_api_connector/utils/__init__.py
12
+ src/datahub_api_connector/utils/site_content_duplicator.py
@@ -0,0 +1,3 @@
1
+ requests
2
+ requests_oauthlib
3
+ oauthlib
@@ -0,0 +1,2 @@
1
+ __init__
2
+ datahub_api_connector