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.
- datahub_api_connector-1.0/LICENSE +19 -0
- datahub_api_connector-1.0/PKG-INFO +78 -0
- datahub_api_connector-1.0/README.md +58 -0
- datahub_api_connector-1.0/pyproject.toml +32 -0
- datahub_api_connector-1.0/setup.cfg +4 -0
- datahub_api_connector-1.0/src/__init__.py +0 -0
- datahub_api_connector-1.0/src/datahub_api_connector/__init__.py +248 -0
- datahub_api_connector-1.0/src/datahub_api_connector/utils/__init__.py +0 -0
- datahub_api_connector-1.0/src/datahub_api_connector/utils/site_content_duplicator.py +260 -0
- datahub_api_connector-1.0/src/datahub_api_connector.egg-info/PKG-INFO +78 -0
- datahub_api_connector-1.0/src/datahub_api_connector.egg-info/SOURCES.txt +12 -0
- datahub_api_connector-1.0/src/datahub_api_connector.egg-info/dependency_links.txt +1 -0
- datahub_api_connector-1.0/src/datahub_api_connector.egg-info/requires.txt +3 -0
- datahub_api_connector-1.0/src/datahub_api_connector.egg-info/top_level.txt +2 -0
|
@@ -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"
|
|
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
|
+
|
|
File without changes
|
|
@@ -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 @@
|
|
|
1
|
+
|