dotstat_io 0.2.7__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.

Potentially problematic release.


This version of dotstat_io might be problematic. Click here for more details.

@@ -0,0 +1,150 @@
1
+ Metadata-Version: 2.1
2
+ Name: dotstat_io
3
+ Version: 0.2.7
4
+ Summary: Utility to download or upload data from/to .Stat Suite using ADFS authentication to connect to it
5
+ License: MIT
6
+ Author: Gyorgy Gyomai
7
+ Author-email: gyorgy.gyomai@oecd.org
8
+ Requires-Python: >=3.10,<4.0
9
+ Classifier: License :: OSI Approved :: MIT License
10
+ Classifier: Programming Language :: Python :: 3
11
+ Classifier: Programming Language :: Python :: 3.10
12
+ Classifier: Programming Language :: Python :: 3.11
13
+ Classifier: Programming Language :: Python :: 3.12
14
+ Requires-Dist: chardet (>=5.1.0,<6.0.0)
15
+ Requires-Dist: msal (>=1.22.0,<2.0.0)
16
+ Requires-Dist: requests (>=2.29.0,<3.0.0)
17
+ Requires-Dist: requests-kerberos (>=0.14.0,<0.15.0)
18
+ Description-Content-Type: text/markdown
19
+
20
+ ### DotStat_IO:
21
+ A generic python package which could be integrated with other end-user applications and Gitlab runner to perform basic io with .Stat Suite.
22
+ Its role is to mask the complexities of authentication to connect to .Stat Suite.
23
+ The user needs to provide a set of parameters and the package exposes a couple of methods which will download or upload data from/to .Stat Suite.
24
+
25
+ ### This package contains three modules:
26
+ - ADFSAuthentication module
27
+ - KeycloakAuthentication module
28
+ - Download_upload module
29
+
30
+ ### In ADFSAuthentication module, four methods are available:
31
+ #### 1. To initialise the module for interactive use:
32
+ ```python
33
+ from dotstat_io.authentication import AdfsAuthentication
34
+ with AdfsAuthentication.interactive(
35
+ client_id=client_id,
36
+ sdmx_resource_id=sdmx_resource_id,
37
+ scopes=scopes,
38
+ authority_url=authority_url,
39
+ redirect_port=redirect_port) as Interactive_obj:
40
+ access_token = Interactive_obj.get_token()
41
+ ```
42
+ * `client_id:` The Application (client) ID that the ADFS assigned to your app
43
+ * `sdmx_resource_id:` The ID of the application to be accessed such as .Stat Suite
44
+ * `scopes:` Scopes requested to access a protected API (a resource defined by sdmx_resource_id)
45
+ * `authority_url:` URL that identifies a token authority
46
+ * `redirect_port:` The port of the address to return to upon receiving a response from the authority
47
+
48
+ #### 2. To initialise the module for non-interactive use using a secret:
49
+ ```python
50
+ from dotstat_io.authentication import AdfsAuthentication
51
+ with AdfsAuthentication.nointeractive_with_secret(
52
+ client_id=client_id,
53
+ sdmx_resource_id=sdmx_resource_id,
54
+ scopes=scopes,
55
+ authority_url=authority_url,
56
+ client_secret=client_secret) as Nointeractive_with_secret_obj:
57
+ access_token = Nointeractive_with_secret_obj.get_token()
58
+ ```
59
+ * `client_id:` The Application (client) ID that the ADFS assigned to your app
60
+ * `sdmx_resource_id:` The ID of the application to be accessed such as .Stat Suite
61
+ * `scopes:` Scopes requested to access a protected API (a resource defined by sdmx_resource_id)
62
+ * `authority_url:` URL that identifies a token authority
63
+ * `client_secret:` The application secret that you created during app registration in ADFS
64
+
65
+ #### 3. To initialise the module for non-interactive use using windows client authentication:
66
+ ```python
67
+ from dotstat_io.authentication import AdfsAuthentication
68
+ with AdfsAuthentication.nointeractive_with_adfs(
69
+ client_id=client_id,
70
+ sdmx_resource_id=sdmx_resource_id,
71
+ token_url=token_url) as Nointeractive_with_adfs_obj:
72
+ access_token = Nointeractive_with_adfs_obj.get_token()
73
+ ```
74
+ * `client_id:` The Application (client) ID that the ADFS assigned to your app
75
+ * `sdmx_resource_id:` The ID of the application to be accessed such as .Stat Suite
76
+ * `token_url:` URL of the authentication service
77
+
78
+ #### 4. To get a token after initialisation of "AdfsAuthentication" object as shown above:
79
+ ```python
80
+ access_token = [Authentication Object Name].get_token()
81
+ ```
82
+
83
+ ### In KeycloakAuthentication module, two methods are available:
84
+ #### 1. To initialise the module for Keycloak authentication:
85
+ ```python
86
+ from dotstat_io.authentication import KeycloakAuthentication
87
+ with KeycloakAuthentication.nointeractive_with_secret(
88
+ user=user,
89
+ password=password,
90
+ token_url=token_url,
91
+ proxy=proxy) as Nointeractive_with_keycloak_obj:
92
+ access_token = Nointeractive_with_keycloak_obj.get_token()
93
+ ```
94
+ * `user:` User name for .Stat Suite authentication
95
+ * `password:` User name's password
96
+ * `token_url:` URL of the authentication service
97
+ * `proxy:` URL of the SSL certificates
98
+
99
+ #### 2. To get a token after initialisation of "KeycloakAuthentication" object as shown above:
100
+ ```python
101
+ access_token = [Authentication Object Name].get_token()
102
+ ```
103
+
104
+ ### In Download_upload module, four methods are available:
105
+ #### 1. To download a file from .Stat Suite:
106
+ ```python
107
+ from dotstat_io.download_upload import Download_upload
108
+ with Download_upload(adfsAuthentication_obj, access_token) as Download_upload_obj:
109
+ Returned_Message = Download_upload_obj.download_file(
110
+ dotstat_url, content_format, Path(file_path))
111
+ ```
112
+ * `dotstat_url:` URL of data to be downloaded from .Stat Suite
113
+ * `content_format:` Format of the downloaded content
114
+ * `file_path:` The full path where the file will downloaded
115
+
116
+ #### 2. To download streamed content from .Stat Suite:
117
+ ```python
118
+ from dotstat_io.download_upload import Download_upload
119
+ with Download_upload(adfsAuthentication_obj, access_token) as Download_upload_obj:
120
+ Returned_Message = Download_upload_obj.download_stream(
121
+ dotstat_url, content_format)
122
+ ```
123
+ * `dotstat_url:` URL of data to be downloaded from .Stat Suite
124
+ * `content_format:` Format of the downloaded content
125
+
126
+ #### 3. To upload a data file to .Stat Suite:
127
+ ```python
128
+ from dotstat_io.download_upload import Download_upload
129
+ with Download_upload(adfsAuthentication_obj, access_token) as Download_upload_obj:
130
+ Returned_Message = Download_upload_obj.upload_file(
131
+ transfer_url, Path(file_path), space, validationType, use_filepath)
132
+ ```
133
+ * `transfer_url:` URL of the transfer service
134
+ * `file_path:` The full path of the SDMX-CSV file, which will be uploaded to .Stat Suite
135
+ * `space:` Data space where the file will be uploaded
136
+ * `validationType:` The type of validation to use during upload. Possible values: Basic Validation (0), Advanced Validation (1)
137
+ * `use_filepath:` Use a file path of a shared folder accessible by the .Stat Suite data upload engine (for unlimited file sizes)
138
+
139
+ #### 4. To upload a structure to .Stat Suite:
140
+ ```python
141
+ from dotstat_io.download_upload import Download_upload
142
+ with Download_upload(adfsAuthentication_obj, access_token) as Download_upload_obj:
143
+ Returned_Message = Download_upload_obj.upload_structure(
144
+ transfer_url, Path(file_path))
145
+ ```
146
+ * `transfer_url:` URL of the transfer service
147
+ * `file_path:` The full path of the SDMX-ML file, which will be uploaded to .Stat Suite
148
+
149
+ ### For more information about how to use this package, all test cases can be accessed from this [`link`](https://gitlab.algobank.oecd.org/sdd-legacy/dotstat_io/-/blob/main/tests/test_cases.py)
150
+
@@ -0,0 +1,130 @@
1
+ ### DotStat_IO:
2
+ A generic python package which could be integrated with other end-user applications and Gitlab runner to perform basic io with .Stat Suite.
3
+ Its role is to mask the complexities of authentication to connect to .Stat Suite.
4
+ The user needs to provide a set of parameters and the package exposes a couple of methods which will download or upload data from/to .Stat Suite.
5
+
6
+ ### This package contains three modules:
7
+ - ADFSAuthentication module
8
+ - KeycloakAuthentication module
9
+ - Download_upload module
10
+
11
+ ### In ADFSAuthentication module, four methods are available:
12
+ #### 1. To initialise the module for interactive use:
13
+ ```python
14
+ from dotstat_io.authentication import AdfsAuthentication
15
+ with AdfsAuthentication.interactive(
16
+ client_id=client_id,
17
+ sdmx_resource_id=sdmx_resource_id,
18
+ scopes=scopes,
19
+ authority_url=authority_url,
20
+ redirect_port=redirect_port) as Interactive_obj:
21
+ access_token = Interactive_obj.get_token()
22
+ ```
23
+ * `client_id:` The Application (client) ID that the ADFS assigned to your app
24
+ * `sdmx_resource_id:` The ID of the application to be accessed such as .Stat Suite
25
+ * `scopes:` Scopes requested to access a protected API (a resource defined by sdmx_resource_id)
26
+ * `authority_url:` URL that identifies a token authority
27
+ * `redirect_port:` The port of the address to return to upon receiving a response from the authority
28
+
29
+ #### 2. To initialise the module for non-interactive use using a secret:
30
+ ```python
31
+ from dotstat_io.authentication import AdfsAuthentication
32
+ with AdfsAuthentication.nointeractive_with_secret(
33
+ client_id=client_id,
34
+ sdmx_resource_id=sdmx_resource_id,
35
+ scopes=scopes,
36
+ authority_url=authority_url,
37
+ client_secret=client_secret) as Nointeractive_with_secret_obj:
38
+ access_token = Nointeractive_with_secret_obj.get_token()
39
+ ```
40
+ * `client_id:` The Application (client) ID that the ADFS assigned to your app
41
+ * `sdmx_resource_id:` The ID of the application to be accessed such as .Stat Suite
42
+ * `scopes:` Scopes requested to access a protected API (a resource defined by sdmx_resource_id)
43
+ * `authority_url:` URL that identifies a token authority
44
+ * `client_secret:` The application secret that you created during app registration in ADFS
45
+
46
+ #### 3. To initialise the module for non-interactive use using windows client authentication:
47
+ ```python
48
+ from dotstat_io.authentication import AdfsAuthentication
49
+ with AdfsAuthentication.nointeractive_with_adfs(
50
+ client_id=client_id,
51
+ sdmx_resource_id=sdmx_resource_id,
52
+ token_url=token_url) as Nointeractive_with_adfs_obj:
53
+ access_token = Nointeractive_with_adfs_obj.get_token()
54
+ ```
55
+ * `client_id:` The Application (client) ID that the ADFS assigned to your app
56
+ * `sdmx_resource_id:` The ID of the application to be accessed such as .Stat Suite
57
+ * `token_url:` URL of the authentication service
58
+
59
+ #### 4. To get a token after initialisation of "AdfsAuthentication" object as shown above:
60
+ ```python
61
+ access_token = [Authentication Object Name].get_token()
62
+ ```
63
+
64
+ ### In KeycloakAuthentication module, two methods are available:
65
+ #### 1. To initialise the module for Keycloak authentication:
66
+ ```python
67
+ from dotstat_io.authentication import KeycloakAuthentication
68
+ with KeycloakAuthentication.nointeractive_with_secret(
69
+ user=user,
70
+ password=password,
71
+ token_url=token_url,
72
+ proxy=proxy) as Nointeractive_with_keycloak_obj:
73
+ access_token = Nointeractive_with_keycloak_obj.get_token()
74
+ ```
75
+ * `user:` User name for .Stat Suite authentication
76
+ * `password:` User name's password
77
+ * `token_url:` URL of the authentication service
78
+ * `proxy:` URL of the SSL certificates
79
+
80
+ #### 2. To get a token after initialisation of "KeycloakAuthentication" object as shown above:
81
+ ```python
82
+ access_token = [Authentication Object Name].get_token()
83
+ ```
84
+
85
+ ### In Download_upload module, four methods are available:
86
+ #### 1. To download a file from .Stat Suite:
87
+ ```python
88
+ from dotstat_io.download_upload import Download_upload
89
+ with Download_upload(adfsAuthentication_obj, access_token) as Download_upload_obj:
90
+ Returned_Message = Download_upload_obj.download_file(
91
+ dotstat_url, content_format, Path(file_path))
92
+ ```
93
+ * `dotstat_url:` URL of data to be downloaded from .Stat Suite
94
+ * `content_format:` Format of the downloaded content
95
+ * `file_path:` The full path where the file will downloaded
96
+
97
+ #### 2. To download streamed content from .Stat Suite:
98
+ ```python
99
+ from dotstat_io.download_upload import Download_upload
100
+ with Download_upload(adfsAuthentication_obj, access_token) as Download_upload_obj:
101
+ Returned_Message = Download_upload_obj.download_stream(
102
+ dotstat_url, content_format)
103
+ ```
104
+ * `dotstat_url:` URL of data to be downloaded from .Stat Suite
105
+ * `content_format:` Format of the downloaded content
106
+
107
+ #### 3. To upload a data file to .Stat Suite:
108
+ ```python
109
+ from dotstat_io.download_upload import Download_upload
110
+ with Download_upload(adfsAuthentication_obj, access_token) as Download_upload_obj:
111
+ Returned_Message = Download_upload_obj.upload_file(
112
+ transfer_url, Path(file_path), space, validationType, use_filepath)
113
+ ```
114
+ * `transfer_url:` URL of the transfer service
115
+ * `file_path:` The full path of the SDMX-CSV file, which will be uploaded to .Stat Suite
116
+ * `space:` Data space where the file will be uploaded
117
+ * `validationType:` The type of validation to use during upload. Possible values: Basic Validation (0), Advanced Validation (1)
118
+ * `use_filepath:` Use a file path of a shared folder accessible by the .Stat Suite data upload engine (for unlimited file sizes)
119
+
120
+ #### 4. To upload a structure to .Stat Suite:
121
+ ```python
122
+ from dotstat_io.download_upload import Download_upload
123
+ with Download_upload(adfsAuthentication_obj, access_token) as Download_upload_obj:
124
+ Returned_Message = Download_upload_obj.upload_structure(
125
+ transfer_url, Path(file_path))
126
+ ```
127
+ * `transfer_url:` URL of the transfer service
128
+ * `file_path:` The full path of the SDMX-ML file, which will be uploaded to .Stat Suite
129
+
130
+ ### For more information about how to use this package, all test cases can be accessed from this [`link`](https://gitlab.algobank.oecd.org/sdd-legacy/dotstat_io/-/blob/main/tests/test_cases.py)
File without changes
@@ -0,0 +1,335 @@
1
+ import requests
2
+ import msal
3
+ import requests_kerberos
4
+ import json
5
+ import time
6
+
7
+ from enum import IntEnum
8
+ from datetime import datetime
9
+
10
+
11
+ class AuthenticationMode(IntEnum):
12
+ INTERACTIVE = 1
13
+ NONINTERACTIVE_WITH_SECRET = 2
14
+ NONINTERACTIVE_WITH_ADFS = 3
15
+
16
+ # class to manage ADFS authentication using OIDC flows
17
+ class AdfsAuthentication():
18
+
19
+ # Declare constants
20
+ __ERROR = "An error occurred: "
21
+ __SUCCESS = "Successful authentication"
22
+
23
+ # Initialise Adfs_authentication
24
+ def __init__(
25
+ self,
26
+ mode: AuthenticationMode,
27
+ client_id: str,
28
+ sdmx_resource_id: str,
29
+ scopes: list[str] = [],
30
+ authority_url: str = None,
31
+ token_url: str = None,
32
+ redirect_port: int = 3000,
33
+ client_secret: str = None
34
+ ):
35
+ self.__access_token = None
36
+
37
+ self.__refresh_token = None
38
+ self.__creation_time = None
39
+ self.__expiration_time = None
40
+
41
+ self.result = None
42
+ self.mode = mode
43
+ self.client_id = client_id
44
+ self.sdmx_resource_id = sdmx_resource_id
45
+ self.scopes = scopes
46
+ self.authority_url = authority_url
47
+ self.token_url = token_url
48
+ self.redirect_port = redirect_port
49
+ self.client_secret = client_secret
50
+
51
+ match self.mode:
52
+ case AuthenticationMode.INTERACTIVE:
53
+ self.app = msal.PublicClientApplication(
54
+ self.client_id, authority=self.authority_url)
55
+ case AuthenticationMode.NONINTERACTIVE_WITH_SECRET:
56
+ self.app = msal.ConfidentialClientApplication(
57
+ self.client_id, authority=self.authority_url, client_credential=self.client_secret)
58
+
59
+ def __enter__(self):
60
+ return self
61
+
62
+ def __exit__(self, exc_type, exc_value, traceback):
63
+ self.__access_token = None
64
+ self.__refresh_token = None
65
+ self.__creation_time = None
66
+ self.__expiration_time = None
67
+
68
+ @classmethod
69
+ def interactive(
70
+ cls,
71
+ client_id: str,
72
+ sdmx_resource_id: str,
73
+ scopes: list[str],
74
+ authority_url: str,
75
+ redirect_port: int = 3000,
76
+ mode: AuthenticationMode = AuthenticationMode.INTERACTIVE
77
+ ):
78
+ return cls(
79
+ client_id=client_id,
80
+ sdmx_resource_id=sdmx_resource_id,
81
+ scopes=scopes,
82
+ authority_url=authority_url,
83
+ redirect_port=redirect_port,
84
+ mode=mode
85
+ )
86
+
87
+ @classmethod
88
+ def nointeractive_with_secret(
89
+ cls,
90
+ client_id: str,
91
+ sdmx_resource_id: str,
92
+ scopes: list[str],
93
+ authority_url: str,
94
+ client_secret: str,
95
+ mode: AuthenticationMode = AuthenticationMode.NONINTERACTIVE_WITH_SECRET
96
+ ):
97
+ return cls(
98
+ client_id=client_id,
99
+ sdmx_resource_id=sdmx_resource_id,
100
+ scopes=scopes,
101
+ authority_url=authority_url,
102
+ client_secret=client_secret,
103
+ mode=mode
104
+ )
105
+
106
+ @classmethod
107
+ def nointeractive_with_adfs(
108
+ cls,
109
+ client_id: str,
110
+ sdmx_resource_id: str,
111
+ token_url: str,
112
+ mode: AuthenticationMode = AuthenticationMode.NONINTERACTIVE_WITH_ADFS
113
+ ):
114
+ return cls(
115
+ client_id=client_id,
116
+ sdmx_resource_id=sdmx_resource_id,
117
+ token_url=token_url,
118
+ mode=mode)
119
+
120
+
121
+ def get_token(self):
122
+ self.__access_token = None
123
+ self.__refresh_token = None
124
+ self.__creation_time = None
125
+ self.__expiration_time = None
126
+ self.result = None
127
+ match self.mode:
128
+ case AuthenticationMode.INTERACTIVE:
129
+ self._acquire_token_interactive()
130
+ case AuthenticationMode.NONINTERACTIVE_WITH_SECRET:
131
+ self._acquire_token_noninteractive_with_secret()
132
+ case AuthenticationMode.NONINTERACTIVE_WITH_ADFS:
133
+ self._acquire_token_noninteractive_with_adfs()
134
+
135
+ return self.__access_token
136
+
137
+ # Check the token is not expired
138
+ def is_access_token_expired(self):
139
+ # convert the timestamp to a datetime object
140
+ if datetime.fromtimestamp(self.__expiration_time) is not None:
141
+ if datetime.now() <= datetime.fromtimestamp(self.__expiration_time):
142
+ return False
143
+ else:
144
+ return True
145
+ else:
146
+ return True
147
+
148
+ # Authentication interactively - aka Authorization Code flow
149
+ def _acquire_token_interactive(self):
150
+ try:
151
+
152
+ # We now check the cache to see
153
+ # whether we already have some accounts that the end user already used to sign in before.
154
+ accounts = self.app.get_accounts()
155
+ if accounts:
156
+ account = accounts[0]
157
+ else:
158
+ account = None
159
+
160
+ # Firstly, looks up a access_token from cache, or using a refresh token
161
+ response_silent = self.app.acquire_token_silent(
162
+ self.scopes, account=account)
163
+ if not response_silent:
164
+ # Prompt the user to sign in interactively
165
+ response_interactive = self.app.acquire_token_interactive(
166
+ scopes=self.scopes, port=self.redirect_port)
167
+ if "access_token" in response_interactive:
168
+ self.__access_token = response_interactive.get("access_token")
169
+ self.__refresh_token = response_interactive.get("refresh_token")
170
+ self.__creation_time = time.time()
171
+ self.__expiration_time = time.time() + int(response_interactive.get("expires_in")) - 60 # one minute margin
172
+ self.result = self.__SUCCESS
173
+ else:
174
+ self.result = f'{self.__ERROR}{response_interactive.get("error")} Error description: {response_interactive.get("error_description")}'
175
+ else:
176
+ if "access_token" in response_silent:
177
+ self.__access_token = response_silent.get("access_token")
178
+ self.__refresh_token = response_silent.get("refresh_token")
179
+ self.__creation_time = time.time()
180
+ self.__expiration_time = time.time() + int(response_silent.get("expires_in")) - 60 # one minute margin
181
+ self.result = self.__SUCCESS
182
+ else:
183
+ self.result = f'{self.__ERROR}{response_silent.get("error")} Error description: {response_silent.get("error_description")}'
184
+ except Exception as err:
185
+ self.result = f'{self.__ERROR}{err}\n'
186
+
187
+
188
+ # Authentication non-interactively using any account - aka Client Credentials flow
189
+ def _acquire_token_noninteractive_with_secret(self):
190
+ try:
191
+ response = self.app.acquire_token_for_client(scopes=self.scopes)
192
+ if "access_token" in response:
193
+ self.__access_token = response.get("access_token")
194
+ self.__creation_time = time.time()
195
+ self.__expiration_time = time.time() + int(response.get("expires_in")) - 60 # one minute margin
196
+ self.result = self.__SUCCESS
197
+ else:
198
+ self.result = f'{self.__ERROR}{response.get("error")} Error description: {response.get("error_description")}'
199
+
200
+ except Exception as err:
201
+ self.result = f'{self.__ERROR}{err}\n'
202
+
203
+
204
+ # Authentication non-interactively using service account - aka Windows Client Authentication
205
+ def _acquire_token_noninteractive_with_adfs(self):
206
+ try:
207
+ headers = {
208
+ "Content-Type": "application/x-www-form-urlencoded"
209
+ }
210
+
211
+ payload = {
212
+ 'client_id': self.client_id,
213
+ 'use_windows_client_authentication': 'true',
214
+ 'grant_type': 'client_credentials',
215
+ 'scope': 'openid',
216
+ 'resource': self.sdmx_resource_id
217
+ }
218
+
219
+ kerberos_auth = requests_kerberos.HTTPKerberosAuth(
220
+ mutual_authentication=requests_kerberos.OPTIONAL, force_preemptive=True)
221
+ response = requests.post(url=self.token_url, data=payload, auth=kerberos_auth)
222
+
223
+ if response.status_code != 200:
224
+ message = f'{self.__ERROR} Error code: {response.status_code} Reason: {response.reason}\n'
225
+ if len(response.text) > 0:
226
+ # Use the json module to load response into a dictionary.
227
+ response_dict = json.loads(response.text)
228
+ message += f'{self.__ERROR}{response_dict.get("error")} Error description: {response_dict.get("error_description")}\n'
229
+
230
+ self.result = message
231
+ else:
232
+ self.__access_token = response.json()['access_token']
233
+ self.__creation_time = time.time()
234
+ self.__expiration_time = time.time() + int(response.json()['expires_in']) - 60 # one minute margin
235
+ self.result = self.__SUCCESS
236
+
237
+ except Exception as err:
238
+ self.result = f'{self.__ERROR} {err}\n'
239
+
240
+
241
+ # class to manage ADFS authentication using OIDC flows
242
+ class KeycloakAuthentication():
243
+
244
+ # Declare constants
245
+ __ERROR = "An error occurred: "
246
+ __SUCCESS = "Successful authentication"
247
+
248
+ # Initialise Adfs_authentication
249
+ def __init__(
250
+ self,
251
+ mode: AuthenticationMode,
252
+ token_url: str,
253
+ user: str,
254
+ password: str,
255
+ client_id: str,
256
+ client_secret: str,
257
+ proxy: str | None,
258
+ scopes: list[str] = []
259
+ ):
260
+ self.__token = None
261
+ self.result = None
262
+ self.mode = mode
263
+ self.client_id = client_id
264
+ self.client_secret = client_secret
265
+ self.token_url = token_url
266
+ self.user = user
267
+ self.password = password
268
+ self.scopes = scopes #TODO check if needs explicit adjustment
269
+
270
+ if proxy:
271
+ self.proxies = {
272
+ "http": proxy,
273
+ "https": proxy
274
+ }
275
+ else:
276
+ self.proxies = None
277
+
278
+
279
+ def __enter__(self):
280
+ return self
281
+
282
+ def __exit__(self, exc_type, exc_value, traceback):
283
+ self.__token = None
284
+
285
+ @classmethod
286
+ def nointeractive_with_secret(
287
+ cls,
288
+ token_url: str,
289
+ user: str,
290
+ password: str,
291
+ client_id: str = "app",
292
+ client_secret: str = "",
293
+ proxy: str | None = None,
294
+ scopes: list[str] = [],
295
+ mode: AuthenticationMode = AuthenticationMode.NONINTERACTIVE_WITH_SECRET
296
+ ):
297
+ return cls(
298
+ token_url=token_url,
299
+ user=user,
300
+ password=password,
301
+ client_id=client_id,
302
+ client_secret=client_secret,
303
+ scopes=scopes,
304
+ proxy=proxy,
305
+ mode=mode
306
+ )
307
+
308
+ def get_token(self):
309
+ self.__token = None
310
+ self.result = None
311
+ match self.mode:
312
+ case AuthenticationMode.NONINTERACTIVE_WITH_SECRET:
313
+ self._acquire_token_noninteractive_with_secret()
314
+
315
+ return self.__token
316
+
317
+ # Authentication non-interactively using any account - aka Client Credentials flow
318
+ def _acquire_token_noninteractive_with_secret(self):
319
+ payload = {
320
+ 'grant_type': 'password',
321
+ 'client_id': self.client_id,
322
+ 'client_secret': self.client_secret,
323
+ 'username': self.user,
324
+ 'password': self.password
325
+ }
326
+
327
+ headers = {"Content-Type": "application/x-www-form-urlencoded"}
328
+
329
+ response = requests.post(self.token_url, proxies=self.proxies, headers=headers, data=payload)
330
+ if response.status_code not in {200, 201}:
331
+ self.result = f'{self.__ERROR}{response}'
332
+ else:
333
+ self.__token = response.json()['access_token']
334
+ self.result = self.__SUCCESS
335
+
@@ -0,0 +1,376 @@
1
+ import os
2
+ import requests
3
+ import chardet
4
+ import logging
5
+ import xml.etree.ElementTree as ET
6
+
7
+ from pathlib import Path
8
+ from time import sleep
9
+
10
+
11
+ FORMAT = '%(message)s'
12
+ logging.basicConfig(format=FORMAT, level=logging.INFO)
13
+ log = logging.getLogger(__name__)
14
+
15
+
16
+ # class to download or upload data from/to .Stat Suite
17
+ class Download_upload():
18
+
19
+ # Declare constants
20
+ __ERROR = "An error occurred: "
21
+ __NO_ACCESS_TOKEN = "No access token"
22
+ __EXECUTION_IN_QUEUED = "Queued"
23
+ __EXECUTION_IN_PROGRESS = "InProgress"
24
+ __CONNECTION_ABORTED = "An existing connection was forcibly closed by the remote host"
25
+ __DOWNLOAD_SUCCESS = "Successful download"
26
+ __UPLOAD_SUCCESS = "The request was successfully processed "
27
+ __UPLOAD_FAILED = "The request failed with status code "
28
+
29
+ __NAMESPACE_MESSAGE = "{http://www.sdmx.org/resources/sdmxml/schemas/v2_1/message}"
30
+ __NAMESPACE_COMMON = "{http://www.sdmx.org/resources/sdmxml/schemas/v2_1/common}"
31
+
32
+ # Initialise Download_upload
33
+ def __init__(self, adfsAuthentication_obj, access_token):
34
+ self.adfsAuthentication_obj = adfsAuthentication_obj
35
+ self.access_token = access_token
36
+
37
+ def __enter__(self):
38
+ return self
39
+
40
+ def __exit__(self, exc_type, exc_value, traceback):
41
+ self.adfsAuthentication_obj = None
42
+ self.access_token = None
43
+
44
+
45
+ # Download a file from .STAT
46
+ def download_file(self, dotstat_url: str, content_format: str, file_path: Path):
47
+ try:
48
+ Returned_Message = ""
49
+
50
+ if (self.access_token == None):
51
+ Returned_Message = self.__ERROR + self.__NO_ACCESS_TOKEN + os.linesep
52
+
53
+ # Write the result to the log
54
+ for line in Returned_Message.split(os.linesep):
55
+ if len(line) > 0:
56
+ log.info(' ' + line)
57
+ else:
58
+ if self.adfsAuthentication_obj.is_access_token_expired():
59
+ self.access_token = self.adfsAuthentication_obj.get_token()
60
+
61
+ headers = {
62
+ 'accept': content_format,
63
+ 'authorization': 'Bearer '+self.access_token
64
+ }
65
+
66
+ #
67
+ response = requests.get(dotstat_url, verify=True, headers=headers)
68
+ except Exception as err:
69
+ Returned_Message = self.__ERROR + str(err) + os.linesep
70
+
71
+ # Write the result to the log
72
+ for line in Returned_Message.split(os.linesep):
73
+ if len(line) > 0:
74
+ log.info(' ' + line)
75
+ else:
76
+ if response.status_code != 200:
77
+ Returned_Message = self.__ERROR + 'Error code: ' + \
78
+ str(response.status_code) + ' Reason: ' + str(response.reason)
79
+ if len(response.text) > 0:
80
+ Returned_Message += os.linesep + 'Text: ' + response.text
81
+ else:
82
+ if os.path.isfile(file_path):
83
+ os.remove(file_path)
84
+ with open(file_path, "wb") as file:
85
+ file.write(response.content)
86
+ Returned_Message = self.__DOWNLOAD_SUCCESS
87
+
88
+ # Write the result to the log
89
+ for line in Returned_Message.split(os.linesep):
90
+ if len(line) > 0:
91
+ log.info(' ' + line)
92
+ finally:
93
+ return Returned_Message
94
+
95
+
96
+ # Download streamed content from .STAT
97
+ def download_stream(self, dotstat_url: str, content_format: str):
98
+ try:
99
+ Returned_Message = ""
100
+
101
+ if (self.access_token == None):
102
+ Returned_Message = self.__ERROR + self.__NO_ACCESS_TOKEN + os.linesep
103
+ else:
104
+ if self.adfsAuthentication_obj.is_access_token_expired():
105
+ self.access_token = self.adfsAuthentication_obj.get_token()
106
+
107
+ headers = {
108
+ 'accept': content_format,
109
+ 'Transfer-Encoding': 'chunked',
110
+ 'authorization': 'Bearer '+self.access_token
111
+ }
112
+
113
+ #
114
+ return requests.get(dotstat_url, verify=True, headers=headers, stream=True)
115
+ except Exception as err:
116
+ Returned_Message = self.__ERROR + str(err) + os.linesep
117
+ return Returned_Message
118
+
119
+
120
+ # Upload a file to .STAT
121
+ def upload_file(self,
122
+ transfer_url: str,
123
+ file_path: Path,
124
+ space: str,
125
+ validationType: int,
126
+ use_filepath: bool = False):
127
+ try:
128
+ Returned_Message = ""
129
+
130
+ if (self.access_token == None):
131
+ Returned_Message = self.__ERROR + self.__NO_ACCESS_TOKEN + os.linesep
132
+
133
+ # Write the result to the log
134
+ for line in Returned_Message.split(os.linesep):
135
+ if len(line) > 0:
136
+ log.info(' ' + line)
137
+ else:
138
+ if self.adfsAuthentication_obj.is_access_token_expired():
139
+ self.access_token = self.adfsAuthentication_obj.get_token()
140
+
141
+ payload = {
142
+ 'dataspace': space,
143
+ 'validationType': validationType
144
+ }
145
+
146
+ headers = {
147
+ 'accept': 'application/json',
148
+ 'authorization': "Bearer "+self.access_token
149
+ }
150
+
151
+ if use_filepath:
152
+ files = {
153
+ 'dataspace': (None, payload['dataspace']),
154
+ 'validationType': (None, payload['validationType']),
155
+ 'filepath': (None, str(file_path))
156
+ }
157
+ else:
158
+ files = {
159
+ 'dataspace': (None, payload['dataspace']),
160
+ 'validationType': (None, payload['validationType']),
161
+ 'file': (os.path.realpath(file_path), open(os.path.realpath(file_path), 'rb'), 'text/csv', '')
162
+ }
163
+
164
+ #
165
+ response = requests.post(transfer_url, verify=True, headers=headers, files=files)
166
+ except Exception as err:
167
+ Returned_Message = self.__ERROR + str(err) + os.linesep
168
+
169
+ # Write the result to the log
170
+ for line in Returned_Message.split(os.linesep):
171
+ if len(line) > 0:
172
+ log.info(' ' + line)
173
+ else:
174
+ if response.status_code != 200:
175
+ Returned_Message = self.__ERROR + 'Error code: ' + \
176
+ str(response.status_code) + ' Reason: ' + str(response.reason)
177
+ if len(response.text) > 0:
178
+ Returned_Message = Returned_Message + os.linesep + 'Text: ' + response.text
179
+
180
+ Returned_Message = Returned_Message + os.linesep
181
+ # Write the result to the log
182
+ for line in Returned_Message.split(os.linesep):
183
+ if len(line) > 0:
184
+ log.info(' ' + line)
185
+ else:
186
+ try:
187
+ Result = response.json()['message']
188
+
189
+ # Write the result to the log
190
+ for line in Result.split(os.linesep):
191
+ if len(line) > 0:
192
+ log.info(' ' + line)
193
+
194
+ Returned_Message = Result + os.linesep
195
+
196
+ # Check the request status
197
+ if (Result != "" and Result.find(self.__ERROR ) == -1):
198
+ # Extract the request ID the returned message
199
+ start = 'with ID'
200
+ end = 'was successfully'
201
+ requestId = Result[Result.find(
202
+ start)+len(start):Result.rfind(end)]
203
+
204
+ # Sleep a little bit before checking the request status
205
+ sleep(3)
206
+
207
+ # To avoid this error: maximum recursion depth exceeded while calling a Python object
208
+ # replace the recursive calls with while loops.
209
+ Result = self.__check_request_status(transfer_url, requestId, space)
210
+
211
+ # Write the result to the log
212
+ for line in Result.split(os.linesep):
213
+ if len(line) > 0:
214
+ log.info(' ' + line)
215
+ sleep(3)
216
+
217
+ Previous_Result = Result
218
+ while Result in [self.__EXECUTION_IN_PROGRESS, self.__EXECUTION_IN_QUEUED, self.__CONNECTION_ABORTED]:
219
+ Result = self.__check_request_status(transfer_url, requestId, space)
220
+
221
+ # Prevent loging again the same information such as "Queued" or "InProgress"
222
+ if Previous_Result != Result:
223
+ Previous_Result = Result
224
+
225
+ # Write the result to the log
226
+ for line in Previous_Result.split(os.linesep):
227
+ if (len(line) > 0 and line not in [self.__EXECUTION_IN_PROGRESS, self.__EXECUTION_IN_QUEUED, self.__CONNECTION_ABORTED]):
228
+ log.info(' ' + line)
229
+ sleep(3)
230
+
231
+ Returned_Message = Returned_Message + Result + os.linesep
232
+ except Exception as err:
233
+ Returned_Message = self.__ERROR + str(err) + os.linesep
234
+ if len(response.text) > 0:
235
+ Returned_Message = Returned_Message + 'Text: ' + response.text + os.linesep
236
+
237
+ # Write the result to the log
238
+ for line in Returned_Message.split(os.linesep):
239
+ if len(line) > 0:
240
+ log.info(' ' + line)
241
+ finally:
242
+ return Returned_Message
243
+
244
+
245
+ # Upload a structure to .STAT
246
+ def upload_structure(self, transfer_url: str, file_path: Path):
247
+ try:
248
+ Returned_Message = ""
249
+
250
+ if (self.access_token == None):
251
+ Returned_Message = self.__ERROR + self.__NO_ACCESS_TOKEN + os.linesep
252
+
253
+ # Write the result to the log
254
+ for line in Returned_Message.split(os.linesep):
255
+ if len(line) > 0:
256
+ log.info(' ' + line)
257
+ else:
258
+ if self.adfsAuthentication_obj.is_access_token_expired():
259
+ self.access_token = self.adfsAuthentication_obj.get_token()
260
+
261
+ # Detect the encoding used in file
262
+ detected_encoding = self.__detect_encode(file_path)
263
+
264
+ # Read file as a string "r+" with the detected encoding
265
+ with open(file=file_path, mode="r+", encoding=detected_encoding.get("encoding")) as file:
266
+ xml_data = file.read()
267
+
268
+ # Make sure the encoding is "utf-8"
269
+ tree = ET.fromstring(xml_data)
270
+ xml_data = ET.tostring(tree, encoding="utf-8", method='xml', xml_declaration=True)
271
+
272
+ headers = {
273
+ 'Content-Type': 'application/xml',
274
+ 'authorization': "Bearer "+self.access_token
275
+ }
276
+
277
+ #
278
+ response = requests.post(transfer_url, verify=True, headers=headers, data=xml_data)
279
+ except Exception as err:
280
+ Returned_Message = self.__ERROR + str(err) + os.linesep
281
+
282
+ # Write the result to the log
283
+ for line in Returned_Message.split(os.linesep):
284
+ if len(line) > 0:
285
+ log.info(' ' + line)
286
+ else:
287
+ try:
288
+ response.raise_for_status()
289
+ except requests.exceptions.HTTPError as e:
290
+ Returned_Message = f'{self.__UPLOAD_FAILED}{response.status_code}: {e}'
291
+
292
+ # Write the result to the log
293
+ for line in Returned_Message.split(os.linesep):
294
+ if len(line) > 0:
295
+ log.info(' ' + line)
296
+ else:
297
+ response_tree = ET.XML(response.content)
298
+ for error_message in response_tree.findall("./{0}ErrorMessage".format(self.__NAMESPACE_MESSAGE)):
299
+ text_element = error_message.find("./{0}Text".format(self.__NAMESPACE_COMMON))
300
+ if (text_element is not None):
301
+ if Returned_Message == "":
302
+ Returned_Message = f'{self.__UPLOAD_SUCCESS}with status code: {response.status_code}' + os.linesep
303
+ Returned_Message = Returned_Message + text_element.text + os.linesep
304
+
305
+ # Write the result to the log
306
+ for line in Returned_Message.split(os.linesep):
307
+ if len(line) > 0:
308
+ log.info(' ' + line)
309
+ finally:
310
+ return Returned_Message
311
+
312
+
313
+ # Detect the encoding used in file
314
+ def __detect_encode(self, file_path):
315
+ detector = chardet.UniversalDetector()
316
+ detector.reset()
317
+ with open(file=file_path, mode="rb") as file:
318
+ for row in file:
319
+ detector.feed(row)
320
+ if detector.done:
321
+ break
322
+
323
+ detector.close()
324
+
325
+ return detector.result
326
+
327
+
328
+ # Check request sent to .STAT status
329
+ # To avoid this error: maximum recursion depth exceeded while calling a Python object
330
+ # replace the recursive calls with while loops.
331
+ def __check_request_status(self, transfer_url, requestId, space):
332
+ try:
333
+ Returned_Message = ""
334
+
335
+ if (self.access_token == None):
336
+ Returned_Message = self.__ERROR + self.__NO_ACCESS_TOKEN + os.linesep
337
+ else:
338
+ if self.adfsAuthentication_obj.is_access_token_expired():
339
+ self.access_token = self.adfsAuthentication_obj.get_token()
340
+
341
+ headers = {
342
+ 'accept': 'application/json',
343
+ 'authorization': "Bearer "+self.access_token
344
+ }
345
+
346
+ payload = {
347
+ 'dataspace': space,
348
+ 'id': requestId
349
+ }
350
+
351
+ transfer_url = transfer_url.replace("import", "status")
352
+ transfer_url = transfer_url.replace("sdmxFile", "request")
353
+
354
+ #
355
+ response = requests.post(transfer_url, verify=True, headers=headers, data=payload)
356
+ except Exception as err:
357
+ Returned_Message = self.__ERROR + str(err)
358
+ else:
359
+ if response.status_code != 200:
360
+ Returned_Message = self.__ERROR + 'Error code: ' + \
361
+ str(response.status_code) + ' Reason: ' + str(response.reason)
362
+ if len(response.text) > 0:
363
+ Returned_Message = Returned_Message + os.linesep + 'Text: ' + response.text
364
+ else:
365
+ executionStatus = 'Execution status: ' + response.json()['executionStatus']
366
+ if response.json()['executionStatus'] in [self.__EXECUTION_IN_PROGRESS, self.__EXECUTION_IN_QUEUED, self.__CONNECTION_ABORTED]:
367
+ Returned_Message = response.json()['executionStatus']
368
+ else:
369
+ Returned_Message = executionStatus + os.linesep + 'Outcome: ' + response.json()['outcome'] + os.linesep
370
+ index = 0
371
+ while index < len(response.json()['logs']):
372
+ Returned_Message = Returned_Message + 'Log' + str(index) + ': ' + response.json()['logs'][index]['message'] + os.linesep
373
+ index += 1
374
+ finally:
375
+ return Returned_Message
376
+
@@ -0,0 +1,23 @@
1
+ [tool.poetry]
2
+ name = "dotstat_io"
3
+ version = "0.2.7"
4
+ description = "Utility to download or upload data from/to .Stat Suite using ADFS authentication to connect to it"
5
+ license = "MIT"
6
+ authors = ["Gyorgy Gyomai <gyorgy.gyomai@oecd.org>", "Abdel Aliaoui <abdel.aliaoui@oecd.org>"]
7
+ readme = "README.md"
8
+ packages = [{include = "dotstat_io"}]
9
+
10
+ [tool.poetry.dependencies]
11
+ python = "^3.10"
12
+ requests = "^2.29.0"
13
+ requests-kerberos = "^0.14.0"
14
+ msal = "^1.22.0"
15
+ chardet = "^5.1.0"
16
+
17
+
18
+ [tool.poetry.group.dev.dependencies]
19
+ pytest = "^7.3.1"
20
+
21
+ [build-system]
22
+ requires = ["poetry-core"]
23
+ build-backend = "poetry.core.masonry.api"