dotstat_io 1.0.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.
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: dotstat_io
|
|
3
|
+
Version: 1.0.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
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
16
|
+
Requires-Dist: chardet (>=5.1.0,<6.0.0)
|
|
17
|
+
Requires-Dist: msal (>=1.22.0,<2.0.0)
|
|
18
|
+
Requires-Dist: requests (>=2.29.0,<3.0.0)
|
|
19
|
+
Requires-Dist: requests-kerberos (>=0.14.0,<0.15.0)
|
|
20
|
+
Requires-Dist: setuptools (>=75.6.0,<76.0.0)
|
|
21
|
+
Description-Content-Type: text/markdown
|
|
22
|
+
|
|
23
|
+
### DotStat_IO package:
|
|
24
|
+
A generic python package which could be integrated with other end-user applications and Gitlab runner to perform basic io with .Stat Suite.
|
|
25
|
+
Its role is to mask the complexities of authentication to connect to .Stat Suite.
|
|
26
|
+
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.
|
|
27
|
+
|
|
28
|
+
### This package contains three modules:
|
|
29
|
+
- ADFSAuthentication module
|
|
30
|
+
- KeycloakAuthentication module
|
|
31
|
+
- Client module
|
|
32
|
+
|
|
33
|
+
### In ADFSAuthentication module, four methods are available:
|
|
34
|
+
#### 1. To initialise the module for interactive use:
|
|
35
|
+
```python
|
|
36
|
+
from dotstat_io.authentication import AdfsAuthentication
|
|
37
|
+
interactive_obj = AdfsAuthentication.interactive(
|
|
38
|
+
client_id=client_id,
|
|
39
|
+
sdmx_resource_id=sdmx_resource_id,
|
|
40
|
+
scopes=scopes,
|
|
41
|
+
authority_url=authority_url,
|
|
42
|
+
redirect_port=redirect_port)
|
|
43
|
+
|
|
44
|
+
```
|
|
45
|
+
* `client_id:` The Application (client) ID that the ADFS assigned to your app
|
|
46
|
+
* `sdmx_resource_id:` The ID of the application to be accessed such as .Stat Suite
|
|
47
|
+
* `scopes:` Scopes requested to access a protected API (a resource defined by sdmx_resource_id)
|
|
48
|
+
* `authority_url:` URL that identifies a token authority
|
|
49
|
+
* `redirect_port:` The port of the address to return to upon receiving a response from the authority
|
|
50
|
+
|
|
51
|
+
#### 2. To initialise the module for non-interactive use using a secret:
|
|
52
|
+
```python
|
|
53
|
+
from dotstat_io.authentication import AdfsAuthentication
|
|
54
|
+
noninteractive_with_secret_obj = AdfsAuthentication.noninteractive_with_secret(
|
|
55
|
+
client_id=client_id,
|
|
56
|
+
sdmx_resource_id=sdmx_resource_id,
|
|
57
|
+
scopes=scopes,
|
|
58
|
+
authority_url=authority_url,
|
|
59
|
+
client_secret=client_secret)
|
|
60
|
+
|
|
61
|
+
```
|
|
62
|
+
* `client_id:` The Application (client) ID that the ADFS assigned to your app
|
|
63
|
+
* `sdmx_resource_id:` The ID of the application to be accessed such as .Stat Suite
|
|
64
|
+
* `scopes:` Scopes requested to access a protected API (a resource defined by sdmx_resource_id)
|
|
65
|
+
* `authority_url:` URL that identifies a token authority
|
|
66
|
+
* `client_secret:` The application secret that you created during app registration in ADFS
|
|
67
|
+
|
|
68
|
+
#### 3. To initialise the module for non-interactive use using windows client authentication:
|
|
69
|
+
```python
|
|
70
|
+
from dotstat_io.authentication import AdfsAuthentication
|
|
71
|
+
noninteractive_with_adfs_obj = AdfsAuthentication.noninteractive_with_adfs(
|
|
72
|
+
client_id=client_id,
|
|
73
|
+
sdmx_resource_id=sdmx_resource_id,
|
|
74
|
+
token_url=token_url)
|
|
75
|
+
|
|
76
|
+
```
|
|
77
|
+
* `client_id:` The Application (client) ID that the ADFS assigned to your app
|
|
78
|
+
* `sdmx_resource_id:` The ID of the application to be accessed such as .Stat Suite
|
|
79
|
+
* `token_url:` URL of the authentication service
|
|
80
|
+
|
|
81
|
+
#### 4. To get a token after initialisation of "AdfsAuthentication" object as shown above:
|
|
82
|
+
```python
|
|
83
|
+
access_token = [Authentication Object Name].get_token()
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
### In KeycloakAuthentication module, two methods are available:
|
|
87
|
+
#### 1. To initialise the module for Keycloak authentication:
|
|
88
|
+
```python
|
|
89
|
+
from dotstat_io.authentication import KeycloakAuthentication
|
|
90
|
+
noninteractive_with_keycloak_obj = KeycloakAuthentication.noninteractive_with_secret(
|
|
91
|
+
user=user,
|
|
92
|
+
password=password,
|
|
93
|
+
token_url=token_url,
|
|
94
|
+
proxy=proxy)
|
|
95
|
+
|
|
96
|
+
```
|
|
97
|
+
* `user:` User name for .Stat Suite authentication
|
|
98
|
+
* `password:` User name's password
|
|
99
|
+
* `token_url:` URL of the authentication service
|
|
100
|
+
* `proxy:` URL of the SSL certificates
|
|
101
|
+
|
|
102
|
+
#### 2. To get a token after initialisation of "KeycloakAuthentication" object as shown above:
|
|
103
|
+
```python
|
|
104
|
+
access_token = [Authentication Object Name].get_token()
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
### In Client module, six methods are available:
|
|
108
|
+
#### 1. To initialise the module using an Authentication object type AdfsAuthentication or KeycloakAuthentication:
|
|
109
|
+
```python
|
|
110
|
+
from dotstat_io.client import Client
|
|
111
|
+
client_obj = Client.init_with_authentication_obj(Authentication_obj)
|
|
112
|
+
```
|
|
113
|
+
* `Authentication_obj:` An initialized authentication object type AdfsAuthentication or KeycloakAuthentication
|
|
114
|
+
|
|
115
|
+
#### 2. To initialise the module using an access token:
|
|
116
|
+
```python
|
|
117
|
+
from dotstat_io.client import Client
|
|
118
|
+
client_obj = Client.init_with_access_token(access_token)
|
|
119
|
+
```
|
|
120
|
+
* `access_token:` An access token to make requests on .Stat Suite services (nsiws) using the authorisation service and underlying permission rules
|
|
121
|
+
|
|
122
|
+
#### 3. To download a file from .Stat Suite:
|
|
123
|
+
```python
|
|
124
|
+
from dotstat_io.client import Client
|
|
125
|
+
client_obj = Client.init_with_authentication_obj(Authentication_obj)
|
|
126
|
+
returned_result = client_obj.download_data_file(
|
|
127
|
+
dotstat_url, content_format, Path(file_path))
|
|
128
|
+
```
|
|
129
|
+
* `dotstat_url:` URL of data to be downloaded from .Stat Suite
|
|
130
|
+
* `content_format:` Format of the downloaded content
|
|
131
|
+
* `file_path:` The full path where the file will downloaded
|
|
132
|
+
|
|
133
|
+
#### 4. To download streamed content from .Stat Suite:
|
|
134
|
+
```python
|
|
135
|
+
from dotstat_io.client import Client
|
|
136
|
+
client_obj = Client.init_with_authentication_obj(Authentication_obj)
|
|
137
|
+
returned_result = client_obj.download_data_stream(
|
|
138
|
+
dotstat_url, content_format)
|
|
139
|
+
```
|
|
140
|
+
* `dotstat_url:` URL of data to be downloaded from .Stat Suite
|
|
141
|
+
* `content_format:` Format of the downloaded content
|
|
142
|
+
|
|
143
|
+
#### 5. To upload a data file to .Stat Suite:
|
|
144
|
+
```python
|
|
145
|
+
from dotstat_io.client import Client
|
|
146
|
+
client_obj = Client.init_with_authentication_obj(Authentication_obj)
|
|
147
|
+
returned_result = client_obj.upload_data_file(
|
|
148
|
+
transfer_url, Path(file_path), space, validationType, use_filepath, optimize)
|
|
149
|
+
```
|
|
150
|
+
* `transfer_url:` URL of the transfer service
|
|
151
|
+
* `file_path:` The full path of the SDMX-CSV file to be imported
|
|
152
|
+
* `space:` Data space where the file will be uploaded
|
|
153
|
+
* `validationType:` The type of validation to use during upload. Possible values: Basic Validation (0), Advanced Validation (1)
|
|
154
|
+
* `use_filepath:` Use a file path of a shared folder accessible by the .Stat Suite data upload engine (for unlimited file sizes)
|
|
155
|
+
* `optimize:` Controls whether optimization is applied after data changes
|
|
156
|
+
|
|
157
|
+
#### 6. To upload an Excel data file to .Stat Suite:
|
|
158
|
+
```python
|
|
159
|
+
from dotstat_io.client import Client
|
|
160
|
+
client_obj = Client.init_with_authentication_obj(Authentication_obj)
|
|
161
|
+
returned_result = client_obj.upload_excel_data_file(
|
|
162
|
+
transfer_url, Path(excelfile_path), Path(eddfile_path), space, validationType)
|
|
163
|
+
```
|
|
164
|
+
* `transfer_url:` URL of the transfer service
|
|
165
|
+
* `excelfile_path:` The full path of the Excel file containing the data/referential metadata values to be imported
|
|
166
|
+
* `eddfile_path:` The full path of the XML edd file containing the description of the Excel file to be imported
|
|
167
|
+
* `space:` Data space where the file will be uploaded
|
|
168
|
+
* `validationType:` The type of validation to use during upload. Possible values: Basic Validation (0), Advanced Validation (1)
|
|
169
|
+
|
|
170
|
+
#### 7. To upload a structure to .Stat Suite:
|
|
171
|
+
```python
|
|
172
|
+
from dotstat_io.client import Client
|
|
173
|
+
client_obj = Client.init_with_authentication_obj(Authentication_obj)
|
|
174
|
+
returned_result = client_obj.upload_structure(
|
|
175
|
+
transfer_url, Path(file_path))
|
|
176
|
+
```
|
|
177
|
+
* `transfer_url:` URL of the transfer service
|
|
178
|
+
* `file_path:` The full path of the SDMX-ML file to be imported
|
|
179
|
+
|
|
180
|
+
### For more information about how to use this package, all test cases can be accessed from this [`link`](https://gitlab.algobank.oecd.org/SD_ENGINEERING/dotstat_io/dotstat_io-package/-/blob/main/tests/test_cases.py)
|
|
181
|
+
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
### DotStat_IO package:
|
|
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
|
+
- Client 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
|
+
interactive_obj = 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)
|
|
21
|
+
|
|
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
|
+
noninteractive_with_secret_obj = AdfsAuthentication.noninteractive_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)
|
|
38
|
+
|
|
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
|
+
noninteractive_with_adfs_obj = AdfsAuthentication.noninteractive_with_adfs(
|
|
50
|
+
client_id=client_id,
|
|
51
|
+
sdmx_resource_id=sdmx_resource_id,
|
|
52
|
+
token_url=token_url)
|
|
53
|
+
|
|
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
|
+
noninteractive_with_keycloak_obj = KeycloakAuthentication.noninteractive_with_secret(
|
|
69
|
+
user=user,
|
|
70
|
+
password=password,
|
|
71
|
+
token_url=token_url,
|
|
72
|
+
proxy=proxy)
|
|
73
|
+
|
|
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 Client module, six methods are available:
|
|
86
|
+
#### 1. To initialise the module using an Authentication object type AdfsAuthentication or KeycloakAuthentication:
|
|
87
|
+
```python
|
|
88
|
+
from dotstat_io.client import Client
|
|
89
|
+
client_obj = Client.init_with_authentication_obj(Authentication_obj)
|
|
90
|
+
```
|
|
91
|
+
* `Authentication_obj:` An initialized authentication object type AdfsAuthentication or KeycloakAuthentication
|
|
92
|
+
|
|
93
|
+
#### 2. To initialise the module using an access token:
|
|
94
|
+
```python
|
|
95
|
+
from dotstat_io.client import Client
|
|
96
|
+
client_obj = Client.init_with_access_token(access_token)
|
|
97
|
+
```
|
|
98
|
+
* `access_token:` An access token to make requests on .Stat Suite services (nsiws) using the authorisation service and underlying permission rules
|
|
99
|
+
|
|
100
|
+
#### 3. To download a file from .Stat Suite:
|
|
101
|
+
```python
|
|
102
|
+
from dotstat_io.client import Client
|
|
103
|
+
client_obj = Client.init_with_authentication_obj(Authentication_obj)
|
|
104
|
+
returned_result = client_obj.download_data_file(
|
|
105
|
+
dotstat_url, content_format, Path(file_path))
|
|
106
|
+
```
|
|
107
|
+
* `dotstat_url:` URL of data to be downloaded from .Stat Suite
|
|
108
|
+
* `content_format:` Format of the downloaded content
|
|
109
|
+
* `file_path:` The full path where the file will downloaded
|
|
110
|
+
|
|
111
|
+
#### 4. To download streamed content from .Stat Suite:
|
|
112
|
+
```python
|
|
113
|
+
from dotstat_io.client import Client
|
|
114
|
+
client_obj = Client.init_with_authentication_obj(Authentication_obj)
|
|
115
|
+
returned_result = client_obj.download_data_stream(
|
|
116
|
+
dotstat_url, content_format)
|
|
117
|
+
```
|
|
118
|
+
* `dotstat_url:` URL of data to be downloaded from .Stat Suite
|
|
119
|
+
* `content_format:` Format of the downloaded content
|
|
120
|
+
|
|
121
|
+
#### 5. To upload a data file to .Stat Suite:
|
|
122
|
+
```python
|
|
123
|
+
from dotstat_io.client import Client
|
|
124
|
+
client_obj = Client.init_with_authentication_obj(Authentication_obj)
|
|
125
|
+
returned_result = client_obj.upload_data_file(
|
|
126
|
+
transfer_url, Path(file_path), space, validationType, use_filepath, optimize)
|
|
127
|
+
```
|
|
128
|
+
* `transfer_url:` URL of the transfer service
|
|
129
|
+
* `file_path:` The full path of the SDMX-CSV file to be imported
|
|
130
|
+
* `space:` Data space where the file will be uploaded
|
|
131
|
+
* `validationType:` The type of validation to use during upload. Possible values: Basic Validation (0), Advanced Validation (1)
|
|
132
|
+
* `use_filepath:` Use a file path of a shared folder accessible by the .Stat Suite data upload engine (for unlimited file sizes)
|
|
133
|
+
* `optimize:` Controls whether optimization is applied after data changes
|
|
134
|
+
|
|
135
|
+
#### 6. To upload an Excel data file to .Stat Suite:
|
|
136
|
+
```python
|
|
137
|
+
from dotstat_io.client import Client
|
|
138
|
+
client_obj = Client.init_with_authentication_obj(Authentication_obj)
|
|
139
|
+
returned_result = client_obj.upload_excel_data_file(
|
|
140
|
+
transfer_url, Path(excelfile_path), Path(eddfile_path), space, validationType)
|
|
141
|
+
```
|
|
142
|
+
* `transfer_url:` URL of the transfer service
|
|
143
|
+
* `excelfile_path:` The full path of the Excel file containing the data/referential metadata values to be imported
|
|
144
|
+
* `eddfile_path:` The full path of the XML edd file containing the description of the Excel file to be imported
|
|
145
|
+
* `space:` Data space where the file will be uploaded
|
|
146
|
+
* `validationType:` The type of validation to use during upload. Possible values: Basic Validation (0), Advanced Validation (1)
|
|
147
|
+
|
|
148
|
+
#### 7. To upload a structure to .Stat Suite:
|
|
149
|
+
```python
|
|
150
|
+
from dotstat_io.client import Client
|
|
151
|
+
client_obj = Client.init_with_authentication_obj(Authentication_obj)
|
|
152
|
+
returned_result = client_obj.upload_structure(
|
|
153
|
+
transfer_url, Path(file_path))
|
|
154
|
+
```
|
|
155
|
+
* `transfer_url:` URL of the transfer service
|
|
156
|
+
* `file_path:` The full path of the SDMX-ML file to be imported
|
|
157
|
+
|
|
158
|
+
### For more information about how to use this package, all test cases can be accessed from this [`link`](https://gitlab.algobank.oecd.org/SD_ENGINEERING/dotstat_io/dotstat_io-package/-/blob/main/tests/test_cases.py)
|
|
File without changes
|
|
@@ -0,0 +1,354 @@
|
|
|
1
|
+
import requests
|
|
2
|
+
import os
|
|
3
|
+
import msal
|
|
4
|
+
import requests_kerberos
|
|
5
|
+
import json
|
|
6
|
+
import time
|
|
7
|
+
|
|
8
|
+
from abc import ABC
|
|
9
|
+
from enum import IntEnum
|
|
10
|
+
from datetime import datetime
|
|
11
|
+
|
|
12
|
+
#
|
|
13
|
+
class AuthenticationMode(IntEnum):
|
|
14
|
+
INTERACTIVE = 1
|
|
15
|
+
NONINTERACTIVE_WITH_SECRET = 2
|
|
16
|
+
NONINTERACTIVE_WITH_ADFS = 3
|
|
17
|
+
|
|
18
|
+
# super class to manage authentication
|
|
19
|
+
class Authentication(ABC):
|
|
20
|
+
|
|
21
|
+
# protected variables
|
|
22
|
+
_access_token = None
|
|
23
|
+
_refresh_token = None
|
|
24
|
+
_creation_time = None
|
|
25
|
+
_expiration_time = None
|
|
26
|
+
|
|
27
|
+
# public variables
|
|
28
|
+
init_status = None
|
|
29
|
+
|
|
30
|
+
# Initialise authentication
|
|
31
|
+
def __init__(
|
|
32
|
+
self,
|
|
33
|
+
client_id: str,
|
|
34
|
+
mode: AuthenticationMode,
|
|
35
|
+
client_secret: str = None,
|
|
36
|
+
scopes: list[str] = [],
|
|
37
|
+
token_url: str = None,
|
|
38
|
+
sdmx_resource_id: str = None,
|
|
39
|
+
authority_url: str = None,
|
|
40
|
+
redirect_port: int = 3000,
|
|
41
|
+
user: str = None,
|
|
42
|
+
password: str = None,
|
|
43
|
+
proxy: str = None
|
|
44
|
+
):
|
|
45
|
+
self._client_id = client_id
|
|
46
|
+
self._mode = mode
|
|
47
|
+
self._client_secret = client_secret
|
|
48
|
+
self._scopes = scopes
|
|
49
|
+
self._token_url = token_url
|
|
50
|
+
self._sdmx_resource_id = sdmx_resource_id
|
|
51
|
+
self._authority_url = authority_url
|
|
52
|
+
self._redirect_port = redirect_port
|
|
53
|
+
self._user = user
|
|
54
|
+
self._password = password
|
|
55
|
+
|
|
56
|
+
if proxy:
|
|
57
|
+
self._proxies = {
|
|
58
|
+
"http": proxy,
|
|
59
|
+
"https": proxy
|
|
60
|
+
}
|
|
61
|
+
else:
|
|
62
|
+
self._proxies = None
|
|
63
|
+
|
|
64
|
+
#
|
|
65
|
+
self._initialize_token()
|
|
66
|
+
|
|
67
|
+
#
|
|
68
|
+
def __enter__(self):
|
|
69
|
+
return self
|
|
70
|
+
|
|
71
|
+
#
|
|
72
|
+
def __exit__(self, exc_type, exc_value, traceback):
|
|
73
|
+
self._access_token = None
|
|
74
|
+
self._refresh_token = None
|
|
75
|
+
self._creation_time = None
|
|
76
|
+
self._expiration_time = None
|
|
77
|
+
self.init_status = None
|
|
78
|
+
|
|
79
|
+
#
|
|
80
|
+
def _initialize_token(self):
|
|
81
|
+
pass
|
|
82
|
+
|
|
83
|
+
#
|
|
84
|
+
def get_token(self):
|
|
85
|
+
if (self._access_token is None) \
|
|
86
|
+
or (datetime.fromtimestamp(self._expiration_time) is not None \
|
|
87
|
+
and datetime.now() >= datetime.fromtimestamp(self._expiration_time)):
|
|
88
|
+
self._initialize_token()
|
|
89
|
+
|
|
90
|
+
return self._access_token
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
# sub class to manage ADFS authentication using OIDC flows
|
|
94
|
+
class AdfsAuthentication(Authentication):
|
|
95
|
+
# private constants
|
|
96
|
+
__ERROR_OCCURRED = "An error occurred: "
|
|
97
|
+
__SUCCESSFUL_AUTHENTICATION = "Successful authentication"
|
|
98
|
+
|
|
99
|
+
#
|
|
100
|
+
def _initialize_token(self):
|
|
101
|
+
self._access_token = None
|
|
102
|
+
self._refresh_token = None
|
|
103
|
+
self._creation_time = None
|
|
104
|
+
self._expiration_time = None
|
|
105
|
+
self.init_status = None
|
|
106
|
+
match self._mode:
|
|
107
|
+
case AuthenticationMode.INTERACTIVE:
|
|
108
|
+
self._app = msal.PublicClientApplication(
|
|
109
|
+
self._client_id, authority=self._authority_url)
|
|
110
|
+
self.__acquire_token_interactive()
|
|
111
|
+
case AuthenticationMode.NONINTERACTIVE_WITH_SECRET:
|
|
112
|
+
self._app = msal.ConfidentialClientApplication(
|
|
113
|
+
self._client_id, authority=self._authority_url, client_credential=self._client_secret)
|
|
114
|
+
self.__acquire_token_noninteractive_with_secret()
|
|
115
|
+
case AuthenticationMode.NONINTERACTIVE_WITH_ADFS:
|
|
116
|
+
self.__acquire_token_noninteractive_with_adfs()
|
|
117
|
+
|
|
118
|
+
#
|
|
119
|
+
@classmethod
|
|
120
|
+
def interactive(
|
|
121
|
+
cls,
|
|
122
|
+
client_id: str,
|
|
123
|
+
sdmx_resource_id: str,
|
|
124
|
+
scopes: list[str],
|
|
125
|
+
authority_url: str,
|
|
126
|
+
redirect_port: int = 3000,
|
|
127
|
+
mode: AuthenticationMode = AuthenticationMode.INTERACTIVE
|
|
128
|
+
):
|
|
129
|
+
return cls(
|
|
130
|
+
client_id=client_id,
|
|
131
|
+
sdmx_resource_id=sdmx_resource_id,
|
|
132
|
+
scopes=scopes,
|
|
133
|
+
authority_url=authority_url,
|
|
134
|
+
redirect_port=redirect_port,
|
|
135
|
+
mode=mode
|
|
136
|
+
)
|
|
137
|
+
|
|
138
|
+
#
|
|
139
|
+
@classmethod
|
|
140
|
+
def noninteractive_with_secret(
|
|
141
|
+
cls,
|
|
142
|
+
client_id: str,
|
|
143
|
+
sdmx_resource_id: str,
|
|
144
|
+
scopes: list[str],
|
|
145
|
+
authority_url: str,
|
|
146
|
+
client_secret: str,
|
|
147
|
+
mode: AuthenticationMode = AuthenticationMode.NONINTERACTIVE_WITH_SECRET
|
|
148
|
+
):
|
|
149
|
+
return cls(
|
|
150
|
+
client_id=client_id,
|
|
151
|
+
sdmx_resource_id=sdmx_resource_id,
|
|
152
|
+
scopes=scopes,
|
|
153
|
+
authority_url=authority_url,
|
|
154
|
+
client_secret=client_secret,
|
|
155
|
+
mode=mode
|
|
156
|
+
)
|
|
157
|
+
|
|
158
|
+
#
|
|
159
|
+
@classmethod
|
|
160
|
+
def noninteractive_with_adfs(
|
|
161
|
+
cls,
|
|
162
|
+
client_id: str,
|
|
163
|
+
sdmx_resource_id: str,
|
|
164
|
+
token_url: str,
|
|
165
|
+
mode: AuthenticationMode = AuthenticationMode.NONINTERACTIVE_WITH_ADFS
|
|
166
|
+
):
|
|
167
|
+
return cls(
|
|
168
|
+
client_id=client_id,
|
|
169
|
+
sdmx_resource_id=sdmx_resource_id,
|
|
170
|
+
token_url=token_url,
|
|
171
|
+
mode=mode)
|
|
172
|
+
|
|
173
|
+
# Authentication interactively - aka Authorization Code flow
|
|
174
|
+
def __acquire_token_interactive(self):
|
|
175
|
+
try:
|
|
176
|
+
# We now check the cache to see
|
|
177
|
+
# whether we already have some accounts that the end user already used to sign in before.
|
|
178
|
+
accounts = self._app.get_accounts()
|
|
179
|
+
if accounts:
|
|
180
|
+
account = accounts[0]
|
|
181
|
+
else:
|
|
182
|
+
account = None
|
|
183
|
+
|
|
184
|
+
# Firstly, looks up a access_token from cache, or using a refresh token
|
|
185
|
+
response_silent = self._app.acquire_token_silent(
|
|
186
|
+
self._scopes, account=account)
|
|
187
|
+
if not response_silent:
|
|
188
|
+
# Prompt the user to sign in interactively
|
|
189
|
+
response_interactive = self._app.acquire_token_interactive(
|
|
190
|
+
scopes=self._scopes, port=self._redirect_port)
|
|
191
|
+
if "access_token" in response_interactive:
|
|
192
|
+
self._access_token = response_interactive.get("access_token")
|
|
193
|
+
self._refresh_token = response_interactive.get("refresh_token")
|
|
194
|
+
self._creation_time = time.time()
|
|
195
|
+
self._expiration_time = time.time() + int(response_interactive.get("expires_in")) - 60 # one minute margin
|
|
196
|
+
self.init_status = self.__SUCCESSFUL_AUTHENTICATION
|
|
197
|
+
else:
|
|
198
|
+
self.init_status = f'{self.__ERROR_OCCURRED}{response_interactive.get("error")} Error description: {response_interactive.get("error_description")}'
|
|
199
|
+
else:
|
|
200
|
+
if "access_token" in response_silent:
|
|
201
|
+
self._access_token = response_silent.get("access_token")
|
|
202
|
+
self._refresh_token = response_silent.get("refresh_token")
|
|
203
|
+
self._creation_time = time.time()
|
|
204
|
+
self._expiration_time = time.time() + int(response_silent.get("expires_in")) - 60 # one minute margin
|
|
205
|
+
self.init_status = self.__SUCCESSFUL_AUTHENTICATION
|
|
206
|
+
else:
|
|
207
|
+
self.init_status = f'{self.__ERROR_OCCURRED}{response_silent.get("error")} Error description: {response_silent.get("error_description")}'
|
|
208
|
+
except Exception as err:
|
|
209
|
+
self.init_status = f'{self.__ERROR_OCCURRED}{err}\n'
|
|
210
|
+
|
|
211
|
+
# Authentication non-interactively using any account - aka Client Credentials flow
|
|
212
|
+
def __acquire_token_noninteractive_with_secret(self):
|
|
213
|
+
try:
|
|
214
|
+
response = self._app.acquire_token_for_client(scopes=self._scopes)
|
|
215
|
+
if "access_token" in response:
|
|
216
|
+
self._access_token = response.get("access_token")
|
|
217
|
+
self._creation_time = time.time()
|
|
218
|
+
self._expiration_time = time.time() + int(response.get("expires_in")) - 60 # one minute margin
|
|
219
|
+
self.init_status = self.__SUCCESSFUL_AUTHENTICATION
|
|
220
|
+
else:
|
|
221
|
+
self.init_status = f'{self.__ERROR_OCCURRED}{response.get("error")} Error description: {response.get("error_description")}'
|
|
222
|
+
|
|
223
|
+
except Exception as err:
|
|
224
|
+
self.init_status = f'{self.__ERROR_OCCURRED}{err}\n'
|
|
225
|
+
|
|
226
|
+
# Authentication non-interactively using service account - aka Windows Client Authentication
|
|
227
|
+
def __acquire_token_noninteractive_with_adfs(self):
|
|
228
|
+
try:
|
|
229
|
+
headers = {
|
|
230
|
+
"Content-Type": "application/x-www-form-urlencoded"
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
payload = {
|
|
234
|
+
'client_id': self._client_id,
|
|
235
|
+
'use_windows_client_authentication': 'true',
|
|
236
|
+
'grant_type': 'client_credentials',
|
|
237
|
+
'scope': 'openid',
|
|
238
|
+
'resource': self._sdmx_resource_id
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
kerberos_auth = requests_kerberos.HTTPKerberosAuth(
|
|
242
|
+
mutual_authentication=requests_kerberos.OPTIONAL, force_preemptive=True)
|
|
243
|
+
response = requests.post(url=self._token_url, data=payload, auth=kerberos_auth)
|
|
244
|
+
|
|
245
|
+
# If the response object cannot be converted to json, return an error
|
|
246
|
+
results_json = None
|
|
247
|
+
try:
|
|
248
|
+
results_json = json.loads(response.text)
|
|
249
|
+
if response.status_code == 200:
|
|
250
|
+
self._access_token = results_json['access_token']
|
|
251
|
+
self._creation_time = time.time()
|
|
252
|
+
self._expiration_time = time.time() + int(results_json['expires_in']) - 60 # one minute margin
|
|
253
|
+
self.init_status = self.__SUCCESSFUL_AUTHENTICATION
|
|
254
|
+
else:
|
|
255
|
+
message = f'{self.__ERROR_OCCURRED} Error code: {response.status_code}'
|
|
256
|
+
if len(str(response.reason)) > 0:
|
|
257
|
+
message += os.linesep + 'Reason: ' + str(response.reason) + os.linesep
|
|
258
|
+
if len(response.text) > 0:
|
|
259
|
+
message += f'{self.__ERROR_OCCURRED}{results_json.get("error")} Error description: {results_json.get("error_description")}\n'
|
|
260
|
+
|
|
261
|
+
self.init_status = message
|
|
262
|
+
except ValueError as err:
|
|
263
|
+
self.init_status = self.__ERROR_OCCURRED + os.linesep
|
|
264
|
+
if len(str(response.status_code)) > 0:
|
|
265
|
+
self.init_status += 'Error code: ' + str(response.status_code) + os.linesep
|
|
266
|
+
if len(str(response.reason)) > 0:
|
|
267
|
+
self.init_status += 'Reason: ' + str(response.reason) + os.linesep
|
|
268
|
+
if len(response.text) > 0:
|
|
269
|
+
self.init_status += str(response.text)
|
|
270
|
+
else:
|
|
271
|
+
self.init_status += str(err)
|
|
272
|
+
except Exception as err:
|
|
273
|
+
self.init_status = f'{self.__ERROR_OCCURRED} {err}\n'
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
# sub class to manage Keycloak authentication
|
|
277
|
+
class KeycloakAuthentication(Authentication):
|
|
278
|
+
# private constants
|
|
279
|
+
__ERROR_OCCURRED = "An error occurred: "
|
|
280
|
+
__SUCCESSFUL_AUTHENTICATION = "Successful authentication"
|
|
281
|
+
|
|
282
|
+
#
|
|
283
|
+
def _initialize_token(self):
|
|
284
|
+
self._access_token = None
|
|
285
|
+
self._refresh_token = None
|
|
286
|
+
self._creation_time = None
|
|
287
|
+
self._expiration_time = None
|
|
288
|
+
self.init_status = None
|
|
289
|
+
match self._mode:
|
|
290
|
+
case AuthenticationMode.NONINTERACTIVE_WITH_SECRET:
|
|
291
|
+
self.__acquire_token_noninteractive_with_secret()
|
|
292
|
+
|
|
293
|
+
#
|
|
294
|
+
@classmethod
|
|
295
|
+
def noninteractive_with_secret(
|
|
296
|
+
cls,
|
|
297
|
+
token_url: str,
|
|
298
|
+
user: str,
|
|
299
|
+
password: str,
|
|
300
|
+
client_id: str = "app",
|
|
301
|
+
client_secret: str = "",
|
|
302
|
+
proxy: str | None = None,
|
|
303
|
+
scopes: list[str] = [],
|
|
304
|
+
mode: AuthenticationMode = AuthenticationMode.NONINTERACTIVE_WITH_SECRET
|
|
305
|
+
):
|
|
306
|
+
return cls(
|
|
307
|
+
token_url=token_url,
|
|
308
|
+
user=user,
|
|
309
|
+
password=password,
|
|
310
|
+
client_id=client_id,
|
|
311
|
+
client_secret=client_secret,
|
|
312
|
+
scopes=scopes,
|
|
313
|
+
proxy=proxy,
|
|
314
|
+
mode=mode
|
|
315
|
+
)
|
|
316
|
+
|
|
317
|
+
# Authentication non-interactively using any account - aka Client Credentials flow
|
|
318
|
+
def __acquire_token_noninteractive_with_secret(self):
|
|
319
|
+
try:
|
|
320
|
+
payload = {
|
|
321
|
+
'grant_type': 'password',
|
|
322
|
+
'client_id': self._client_id,
|
|
323
|
+
'client_secret': self._client_secret,
|
|
324
|
+
'username': self._user,
|
|
325
|
+
'password': self._password
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
headers = {"Content-Type": "application/x-www-form-urlencoded"}
|
|
329
|
+
|
|
330
|
+
response = requests.post(self._token_url, proxies=self._proxies, headers=headers, data=payload)
|
|
331
|
+
if response.status_code not in {200, 201}:
|
|
332
|
+
self.init_status = f'{self.__ERROR_OCCURRED}{response}'
|
|
333
|
+
else:
|
|
334
|
+
# If the response object cannot be converted to json, return an error
|
|
335
|
+
results_json = None
|
|
336
|
+
try:
|
|
337
|
+
results_json = json.loads(response.text)
|
|
338
|
+
self._access_token = results_json['access_token']
|
|
339
|
+
self._refresh_token = results_json['refresh_token']
|
|
340
|
+
self._creation_time = time.time()
|
|
341
|
+
self._expiration_time = time.time() + int(results_json['expires_in']) - 60 # one minute margin
|
|
342
|
+
self.init_status = self.__SUCCESSFUL_AUTHENTICATION
|
|
343
|
+
except ValueError as err:
|
|
344
|
+
self.init_status = self.__ERROR_OCCURRED + os.linesep
|
|
345
|
+
if len(str(response.status_code)) > 0:
|
|
346
|
+
self.init_status += 'Error code: ' + str(response.status_code) + os.linesep
|
|
347
|
+
if len(str(response.reason)) > 0:
|
|
348
|
+
self.init_status += 'Reason: ' + str(response.reason) + os.linesep
|
|
349
|
+
if len(response.text) > 0:
|
|
350
|
+
self.init_status += str(response.text)
|
|
351
|
+
else:
|
|
352
|
+
self.init_status += str(err)
|
|
353
|
+
except Exception as err:
|
|
354
|
+
self.init_status = f'{self.__ERROR_OCCURRED}{err}\n'
|
|
@@ -0,0 +1,552 @@
|
|
|
1
|
+
from enum import IntEnum
|
|
2
|
+
import os
|
|
3
|
+
import requests
|
|
4
|
+
import chardet
|
|
5
|
+
import json
|
|
6
|
+
import logging
|
|
7
|
+
import xml.etree.ElementTree as ET
|
|
8
|
+
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from time import sleep
|
|
11
|
+
|
|
12
|
+
from dotstat_io.authentication import Authentication
|
|
13
|
+
|
|
14
|
+
class ValidationType(IntEnum):
|
|
15
|
+
BASIC = 0
|
|
16
|
+
ADVANCED = 1
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
# class to download or upload data from/to .Stat Suite
|
|
20
|
+
class Client():
|
|
21
|
+
|
|
22
|
+
# private constants
|
|
23
|
+
__ERROR_OCCURRED = "An error occurred: "
|
|
24
|
+
__EXECUTION_IN_QUEUED = "Queued"
|
|
25
|
+
__EXECUTION_IN_PROGRESS = "InProgress"
|
|
26
|
+
__CONNECTION_ABORTED = "An existing connection was forcibly closed by the remote host"
|
|
27
|
+
__DOWNLOAD_SUCCESS = "Successful download"
|
|
28
|
+
__UPLOAD_SUCCESS = "The request was successfully processed "
|
|
29
|
+
__UPLOAD_FAILED = "The request failed with status code "
|
|
30
|
+
|
|
31
|
+
__NAMESPACE_MESSAGE = "{http://www.sdmx.org/resources/sdmxml/schemas/v2_1/message}"
|
|
32
|
+
__NAMESPACE_COMMON = "{http://www.sdmx.org/resources/sdmxml/schemas/v2_1/common}"
|
|
33
|
+
|
|
34
|
+
# private variables
|
|
35
|
+
__access_token = None
|
|
36
|
+
__authentication_obj = None
|
|
37
|
+
|
|
38
|
+
# Prepare logging format
|
|
39
|
+
FORMAT = '%(message)s'
|
|
40
|
+
logging.basicConfig(format=FORMAT, level=logging.INFO)
|
|
41
|
+
__log = logging.getLogger(__name__)
|
|
42
|
+
|
|
43
|
+
# Initialise Client
|
|
44
|
+
def __init__(self,
|
|
45
|
+
access_token: None | str = None,
|
|
46
|
+
authentication_obj: None | Authentication = None):
|
|
47
|
+
Client.__access_token = access_token
|
|
48
|
+
Client.__authentication_obj = authentication_obj
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
#
|
|
52
|
+
def __enter__(self):
|
|
53
|
+
return self
|
|
54
|
+
|
|
55
|
+
#
|
|
56
|
+
def __exit__(self, exc_type, exc_value, traceback):
|
|
57
|
+
self.__access_token = None
|
|
58
|
+
self.__authentication_obj = None
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
#
|
|
62
|
+
@classmethod
|
|
63
|
+
def init_with_access_token(
|
|
64
|
+
cls,
|
|
65
|
+
access_token: str
|
|
66
|
+
):
|
|
67
|
+
return cls(
|
|
68
|
+
access_token=access_token
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
#
|
|
72
|
+
@classmethod
|
|
73
|
+
def init_with_authentication_obj(
|
|
74
|
+
cls,
|
|
75
|
+
authentication_obj: Authentication
|
|
76
|
+
):
|
|
77
|
+
return cls(
|
|
78
|
+
authentication_obj=authentication_obj
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
# Download a data file from .Stat Suite
|
|
83
|
+
def download_data_file(self, dotstat_url: str, content_format: str, file_path: Path):
|
|
84
|
+
try:
|
|
85
|
+
returned_result = ""
|
|
86
|
+
|
|
87
|
+
#
|
|
88
|
+
if Client.__authentication_obj is not None:
|
|
89
|
+
Client.__access_token = Client.__authentication_obj.get_token()
|
|
90
|
+
|
|
91
|
+
headers = {
|
|
92
|
+
'accept': content_format,
|
|
93
|
+
'authorization': 'Bearer '+Client.__access_token
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
#
|
|
97
|
+
response = requests.get(dotstat_url, verify=True, headers=headers)
|
|
98
|
+
except Exception as err:
|
|
99
|
+
returned_result = Client.__ERROR_OCCURRED + str(err) + os.linesep
|
|
100
|
+
|
|
101
|
+
# Write the result to the log
|
|
102
|
+
for line in returned_result.split(os.linesep):
|
|
103
|
+
if len(line) > 0:
|
|
104
|
+
self.__log.info(' ' + line)
|
|
105
|
+
return returned_result
|
|
106
|
+
else:
|
|
107
|
+
if response.status_code != 200:
|
|
108
|
+
returned_result = Client.__ERROR_OCCURRED
|
|
109
|
+
if len(str(response.status_code)) > 0:
|
|
110
|
+
returned_result += 'Error code: ' + str(response.status_code) + os.linesep
|
|
111
|
+
if len(str(response.reason)) > 0:
|
|
112
|
+
returned_result += 'Reason: ' + str(response.reason) + os.linesep
|
|
113
|
+
if len(response.text) > 0:
|
|
114
|
+
returned_result += 'Text: ' + response.text
|
|
115
|
+
|
|
116
|
+
returned_result += os.linesep
|
|
117
|
+
else:
|
|
118
|
+
if os.path.isfile(file_path):
|
|
119
|
+
os.remove(file_path)
|
|
120
|
+
with open(file_path, "wb") as file:
|
|
121
|
+
file.write(response.content)
|
|
122
|
+
returned_result = Client.__DOWNLOAD_SUCCESS
|
|
123
|
+
|
|
124
|
+
# Write the result to the log
|
|
125
|
+
for line in returned_result.split(os.linesep):
|
|
126
|
+
if len(line) > 0:
|
|
127
|
+
self.__log.info(' ' + line)
|
|
128
|
+
return returned_result
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
# Download streamed content from .Stat Suite
|
|
132
|
+
def download_data_stream(self, dotstat_url: str, content_format: str):
|
|
133
|
+
try:
|
|
134
|
+
returned_result = ""
|
|
135
|
+
|
|
136
|
+
#
|
|
137
|
+
if Client.__authentication_obj is not None:
|
|
138
|
+
Client.__access_token = Client.__authentication_obj.get_token()
|
|
139
|
+
|
|
140
|
+
headers = {
|
|
141
|
+
'accept': content_format,
|
|
142
|
+
'Transfer-Encoding': 'chunked',
|
|
143
|
+
'authorization': 'Bearer '+Client.__access_token
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
#
|
|
147
|
+
return requests.get(dotstat_url, verify=True, headers=headers, stream=True)
|
|
148
|
+
except Exception as err:
|
|
149
|
+
returned_result = Client.__ERROR_OCCURRED + str(err) + os.linesep
|
|
150
|
+
return returned_result
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
# Upload a data file to .Stat Suite
|
|
154
|
+
def upload_data_file(self,
|
|
155
|
+
transfer_url: str,
|
|
156
|
+
file_path: Path,
|
|
157
|
+
space: str,
|
|
158
|
+
validationType: int,
|
|
159
|
+
use_filepath: bool = False,
|
|
160
|
+
optimize: bool = True):
|
|
161
|
+
try:
|
|
162
|
+
returned_result = ""
|
|
163
|
+
|
|
164
|
+
#
|
|
165
|
+
if Client.__authentication_obj is not None:
|
|
166
|
+
Client.__access_token = Client.__authentication_obj.get_token()
|
|
167
|
+
|
|
168
|
+
payload = {
|
|
169
|
+
'dataspace': space,
|
|
170
|
+
'validationType': validationType,
|
|
171
|
+
'optimize': optimize
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
headers = {
|
|
175
|
+
'accept': 'application/json',
|
|
176
|
+
'authorization': "Bearer "+Client.__access_token
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
if use_filepath:
|
|
180
|
+
files = {
|
|
181
|
+
'dataspace': (None, payload['dataspace']),
|
|
182
|
+
'validationType': (None, payload['validationType']),
|
|
183
|
+
'optimize': (None, payload['optimize']),
|
|
184
|
+
'filepath': (None, str(file_path))
|
|
185
|
+
}
|
|
186
|
+
else:
|
|
187
|
+
files = {
|
|
188
|
+
'dataspace': (None, payload['dataspace']),
|
|
189
|
+
'validationType': (None, payload['validationType']),
|
|
190
|
+
'optimize': (None, payload['optimize']),
|
|
191
|
+
'file': (os.path.realpath(file_path), open(os.path.realpath(file_path), 'rb'), 'text/csv', '')
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
#
|
|
195
|
+
response = requests.post(transfer_url, verify=True, headers=headers, files=files)
|
|
196
|
+
except Exception as err:
|
|
197
|
+
returned_result = Client.__ERROR_OCCURRED + str(err) + os.linesep
|
|
198
|
+
|
|
199
|
+
# Write the result to the log
|
|
200
|
+
for line in returned_result.split(os.linesep):
|
|
201
|
+
if len(line) > 0:
|
|
202
|
+
self.__log.info(' ' + line)
|
|
203
|
+
return returned_result
|
|
204
|
+
else:
|
|
205
|
+
# If the response object cannot be converted to json, return an error
|
|
206
|
+
results_json = None
|
|
207
|
+
try:
|
|
208
|
+
results_json = json.loads(response.text)
|
|
209
|
+
if response.status_code == 200:
|
|
210
|
+
result = results_json['message']
|
|
211
|
+
# Write the result to the log
|
|
212
|
+
for line in result.split(os.linesep):
|
|
213
|
+
if len(line) > 0:
|
|
214
|
+
self.__log.info(' ' + line)
|
|
215
|
+
|
|
216
|
+
returned_result = result + os.linesep
|
|
217
|
+
|
|
218
|
+
# Check the request status
|
|
219
|
+
if (result != "" and result.find(Client.__ERROR_OCCURRED ) == -1):
|
|
220
|
+
# Extract the request ID the returned message
|
|
221
|
+
start = 'with ID'
|
|
222
|
+
end = 'was successfully'
|
|
223
|
+
requestId = result[result.find(
|
|
224
|
+
start)+len(start):result.rfind(end)]
|
|
225
|
+
|
|
226
|
+
# Sleep a little bit before checking the request status
|
|
227
|
+
sleep(3)
|
|
228
|
+
|
|
229
|
+
# To avoid this error: maximum recursion depth exceeded while calling a Python object
|
|
230
|
+
# replace the recursive calls with while loops.
|
|
231
|
+
result = self.__check_request_status(transfer_url, requestId, space)
|
|
232
|
+
|
|
233
|
+
# Write the result to the log
|
|
234
|
+
for line in result.split(os.linesep):
|
|
235
|
+
if len(line) > 0:
|
|
236
|
+
self.__log.info(' ' + line)
|
|
237
|
+
sleep(3)
|
|
238
|
+
|
|
239
|
+
previous_result = result
|
|
240
|
+
while (result in [Client.__EXECUTION_IN_PROGRESS, Client.__EXECUTION_IN_QUEUED]
|
|
241
|
+
or Client.__CONNECTION_ABORTED in result):
|
|
242
|
+
result = self.__check_request_status(transfer_url, requestId, space)
|
|
243
|
+
|
|
244
|
+
# Prevent loging again the same information such as "Queued" or "InProgress"
|
|
245
|
+
if previous_result != result:
|
|
246
|
+
previous_result = result
|
|
247
|
+
|
|
248
|
+
# Write the result to the log
|
|
249
|
+
for line in previous_result.split(os.linesep):
|
|
250
|
+
if (len(line) > 0 and line not in [Client.__EXECUTION_IN_PROGRESS, Client.__EXECUTION_IN_QUEUED]
|
|
251
|
+
and Client.__CONNECTION_ABORTED not in line):
|
|
252
|
+
self.__log.info(' ' + line)
|
|
253
|
+
sleep(3)
|
|
254
|
+
|
|
255
|
+
returned_result = returned_result + result + os.linesep
|
|
256
|
+
else:
|
|
257
|
+
returned_result = Client.__ERROR_OCCURRED
|
|
258
|
+
if len(str(response.status_code)) > 0:
|
|
259
|
+
returned_result += 'Error code: ' + str(response.status_code) + os.linesep
|
|
260
|
+
if len(str(response.reason)) > 0:
|
|
261
|
+
returned_result += 'Reason: ' + str(response.reason) + os.linesep
|
|
262
|
+
if len(response.text) > 0:
|
|
263
|
+
returned_result += 'Text: ' + response.text
|
|
264
|
+
|
|
265
|
+
returned_result += os.linesep
|
|
266
|
+
# Write the result to the log
|
|
267
|
+
for line in returned_result.split(os.linesep):
|
|
268
|
+
if len(line) > 0:
|
|
269
|
+
self.__log.info(' ' + line)
|
|
270
|
+
except ValueError as err:
|
|
271
|
+
returned_result = Client.__ERROR_OCCURRED
|
|
272
|
+
if len(str(response.status_code)) > 0:
|
|
273
|
+
returned_result += 'Error code: ' + str(response.status_code) + os.linesep
|
|
274
|
+
if len(str(response.reason)) > 0:
|
|
275
|
+
returned_result += 'Reason: ' + str(response.reason) + os.linesep
|
|
276
|
+
if len(response.text) > 0:
|
|
277
|
+
returned_result += 'Text: ' + str(response.text)
|
|
278
|
+
else:
|
|
279
|
+
returned_result += str(err)
|
|
280
|
+
returned_result += os.linesep
|
|
281
|
+
return returned_result
|
|
282
|
+
|
|
283
|
+
|
|
284
|
+
# Upload an Excel data file to .Stat Suite
|
|
285
|
+
def upload_excel_data_file(self,
|
|
286
|
+
transfer_url: str,
|
|
287
|
+
excelfile_path: Path,
|
|
288
|
+
eddfile_path: Path,
|
|
289
|
+
space: str,
|
|
290
|
+
validationType: int):
|
|
291
|
+
try:
|
|
292
|
+
returned_result = ""
|
|
293
|
+
|
|
294
|
+
#
|
|
295
|
+
if Client.__authentication_obj is not None:
|
|
296
|
+
Client.__access_token = Client.__authentication_obj.get_token()
|
|
297
|
+
|
|
298
|
+
payload = {
|
|
299
|
+
'dataspace': space,
|
|
300
|
+
'validationType': validationType
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
headers = {
|
|
304
|
+
'accept': 'application/json',
|
|
305
|
+
'authorization': "Bearer "+Client.__access_token
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
excel_file = open(os.path.realpath(excelfile_path), 'rb')
|
|
309
|
+
eddfile_file = open(os.path.realpath(eddfile_path), 'rb')
|
|
310
|
+
files = {
|
|
311
|
+
'dataspace': (None, payload['dataspace']),
|
|
312
|
+
'validationType': (None, payload['validationType']),
|
|
313
|
+
'excelFile': (str(excelfile_path), excel_file, 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', ''),
|
|
314
|
+
'eddFile': (str(eddfile_path), eddfile_file, 'text/xml', '')
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
#
|
|
318
|
+
response = requests.post(transfer_url, verify=True, headers=headers, files=files)
|
|
319
|
+
except Exception as err:
|
|
320
|
+
returned_result = Client.__ERROR_OCCURRED + str(err) + os.linesep
|
|
321
|
+
|
|
322
|
+
# Write the result to the log
|
|
323
|
+
for line in returned_result.split(os.linesep):
|
|
324
|
+
if len(line) > 0:
|
|
325
|
+
self.__log.info(' ' + line)
|
|
326
|
+
return returned_result
|
|
327
|
+
else:
|
|
328
|
+
# If the response object cannot be converted to json, return an error
|
|
329
|
+
results_json = None
|
|
330
|
+
try:
|
|
331
|
+
results_json = json.loads(response.text)
|
|
332
|
+
if response.status_code == 200:
|
|
333
|
+
result = results_json['message']
|
|
334
|
+
# Write the result to the log
|
|
335
|
+
for line in result.split(os.linesep):
|
|
336
|
+
if len(line) > 0:
|
|
337
|
+
self.__log.info(' ' + line)
|
|
338
|
+
|
|
339
|
+
returned_result = result + os.linesep
|
|
340
|
+
|
|
341
|
+
# Check the request status
|
|
342
|
+
if (result != "" and result.find(Client.__ERROR_OCCURRED ) == -1):
|
|
343
|
+
# Extract the request ID the returned message
|
|
344
|
+
start = 'with ID'
|
|
345
|
+
end = 'was successfully'
|
|
346
|
+
requestId = result[result.find(
|
|
347
|
+
start)+len(start):result.rfind(end)]
|
|
348
|
+
|
|
349
|
+
# Sleep a little bit before checking the request status
|
|
350
|
+
sleep(3)
|
|
351
|
+
|
|
352
|
+
# To avoid this error: maximum recursion depth exceeded while calling a Python object
|
|
353
|
+
# replace the recursive calls with while loops.
|
|
354
|
+
result = self.__check_request_status(transfer_url, requestId, space)
|
|
355
|
+
|
|
356
|
+
# Write the result to the log
|
|
357
|
+
for line in result.split(os.linesep):
|
|
358
|
+
if len(line) > 0:
|
|
359
|
+
self.__log.info(' ' + line)
|
|
360
|
+
sleep(3)
|
|
361
|
+
|
|
362
|
+
previous_result = result
|
|
363
|
+
while (result in [Client.__EXECUTION_IN_PROGRESS, Client.__EXECUTION_IN_QUEUED]
|
|
364
|
+
or Client.__CONNECTION_ABORTED in result):
|
|
365
|
+
result = self.__check_request_status(transfer_url, requestId, space)
|
|
366
|
+
|
|
367
|
+
# Prevent loging again the same information such as "Queued" or "InProgress"
|
|
368
|
+
if previous_result != result:
|
|
369
|
+
previous_result = result
|
|
370
|
+
|
|
371
|
+
# Write the result to the log
|
|
372
|
+
for line in previous_result.split(os.linesep):
|
|
373
|
+
if (len(line) > 0 and line not in [Client.__EXECUTION_IN_PROGRESS, Client.__EXECUTION_IN_QUEUED]
|
|
374
|
+
and Client.__CONNECTION_ABORTED not in line):
|
|
375
|
+
self.__log.info(' ' + line)
|
|
376
|
+
sleep(3)
|
|
377
|
+
|
|
378
|
+
returned_result = returned_result + result + os.linesep
|
|
379
|
+
else:
|
|
380
|
+
returned_result = Client.__ERROR_OCCURRED
|
|
381
|
+
if len(str(response.status_code)) > 0:
|
|
382
|
+
returned_result += 'Error code: ' + str(response.status_code) + os.linesep
|
|
383
|
+
if len(str(response.reason)) > 0:
|
|
384
|
+
returned_result += 'Reason: ' + str(response.reason) + os.linesep
|
|
385
|
+
if len(response.text) > 0:
|
|
386
|
+
returned_result += 'Text: ' + response.text
|
|
387
|
+
|
|
388
|
+
returned_result += os.linesep
|
|
389
|
+
# Write the result to the log
|
|
390
|
+
for line in returned_result.split(os.linesep):
|
|
391
|
+
if len(line) > 0:
|
|
392
|
+
self.__log.info(' ' + line)
|
|
393
|
+
except ValueError as err:
|
|
394
|
+
returned_result = Client.__ERROR_OCCURRED
|
|
395
|
+
if len(str(response.status_code)) > 0:
|
|
396
|
+
returned_result += 'Error code: ' + str(response.status_code) + os.linesep
|
|
397
|
+
if len(str(response.reason)) > 0:
|
|
398
|
+
returned_result += 'Reason: ' + str(response.reason) + os.linesep
|
|
399
|
+
if len(response.text) > 0:
|
|
400
|
+
returned_result += 'Text: ' + str(response.text)
|
|
401
|
+
else:
|
|
402
|
+
returned_result += str(err)
|
|
403
|
+
returned_result += os.linesep
|
|
404
|
+
return returned_result
|
|
405
|
+
|
|
406
|
+
|
|
407
|
+
# Upload a structure file to .Stat Suite
|
|
408
|
+
def upload_structure(self, transfer_url: str, file_path: Path):
|
|
409
|
+
try:
|
|
410
|
+
returned_result = ""
|
|
411
|
+
|
|
412
|
+
#
|
|
413
|
+
if Client.__authentication_obj is not None:
|
|
414
|
+
Client.__access_token = Client.__authentication_obj.get_token()
|
|
415
|
+
|
|
416
|
+
# Detect the encoding used in file
|
|
417
|
+
detected_encoding = self.__detect_encode(file_path)
|
|
418
|
+
|
|
419
|
+
# Read file as a string "r+" with the detected encoding
|
|
420
|
+
with open(file=file_path, mode="r+", encoding=detected_encoding.get("encoding")) as file:
|
|
421
|
+
xml_data = file.read()
|
|
422
|
+
|
|
423
|
+
# Make sure the encoding is "utf-8"
|
|
424
|
+
tree = ET.fromstring(xml_data)
|
|
425
|
+
xml_data = ET.tostring(tree, encoding="utf-8", method='xml', xml_declaration=True)
|
|
426
|
+
|
|
427
|
+
headers = {
|
|
428
|
+
'Content-Type': 'application/xml',
|
|
429
|
+
'authorization': "Bearer "+Client.__access_token
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
#
|
|
433
|
+
response = requests.post(transfer_url, verify=True, headers=headers, data=xml_data)
|
|
434
|
+
except Exception as err:
|
|
435
|
+
returned_result = Client.__ERROR_OCCURRED + str(err) + os.linesep
|
|
436
|
+
|
|
437
|
+
# Write the result to the log
|
|
438
|
+
for line in returned_result.split(os.linesep):
|
|
439
|
+
if len(line) > 0:
|
|
440
|
+
self.__log.info(' ' + line)
|
|
441
|
+
return returned_result
|
|
442
|
+
else:
|
|
443
|
+
try:
|
|
444
|
+
response.raise_for_status()
|
|
445
|
+
except requests.exceptions.HTTPError as e:
|
|
446
|
+
returned_result = f'{Client.__UPLOAD_FAILED}{response.status_code}: {e}'
|
|
447
|
+
|
|
448
|
+
# Write the result to the log
|
|
449
|
+
for line in returned_result.split(os.linesep):
|
|
450
|
+
if len(line) > 0:
|
|
451
|
+
self.__log.info(' ' + line)
|
|
452
|
+
else:
|
|
453
|
+
response_tree = ET.XML(response.content)
|
|
454
|
+
for element in response_tree.findall("./{0}ErrorMessage".format(Client.__NAMESPACE_MESSAGE)):
|
|
455
|
+
text_element = element.find("./{0}Text".format(Client.__NAMESPACE_COMMON))
|
|
456
|
+
if (text_element is not None):
|
|
457
|
+
if returned_result == "":
|
|
458
|
+
returned_result = f'{Client.__UPLOAD_SUCCESS}with status code: {response.status_code}' + os.linesep
|
|
459
|
+
returned_result = returned_result + text_element.text + os.linesep
|
|
460
|
+
|
|
461
|
+
# Write the result to the log
|
|
462
|
+
for line in returned_result.split(os.linesep):
|
|
463
|
+
if len(line) > 0:
|
|
464
|
+
self.__log.info(' ' + line)
|
|
465
|
+
return returned_result
|
|
466
|
+
|
|
467
|
+
|
|
468
|
+
# Detect the encoding used in file
|
|
469
|
+
def __detect_encode(self, file_path):
|
|
470
|
+
detector = chardet.UniversalDetector()
|
|
471
|
+
detector.reset()
|
|
472
|
+
with open(file=file_path, mode="rb") as file:
|
|
473
|
+
for row in file:
|
|
474
|
+
detector.feed(row)
|
|
475
|
+
if detector.done:
|
|
476
|
+
break
|
|
477
|
+
|
|
478
|
+
detector.close()
|
|
479
|
+
|
|
480
|
+
return detector.result
|
|
481
|
+
|
|
482
|
+
|
|
483
|
+
# Check request sent to .Stat Suite status
|
|
484
|
+
# To avoid this error: maximum recursion depth exceeded while calling a Python object
|
|
485
|
+
# replace the recursive calls with while loops.
|
|
486
|
+
def __check_request_status(self, transfer_url, requestId, space):
|
|
487
|
+
try:
|
|
488
|
+
returned_result = ""
|
|
489
|
+
|
|
490
|
+
#
|
|
491
|
+
if Client.__authentication_obj is not None:
|
|
492
|
+
Client.__access_token = Client.__authentication_obj.get_token()
|
|
493
|
+
|
|
494
|
+
headers = {
|
|
495
|
+
'accept': 'application/json',
|
|
496
|
+
'authorization': "Bearer "+Client.__access_token
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
payload = {
|
|
500
|
+
'dataspace': space,
|
|
501
|
+
'id': requestId
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
transfer_url = transfer_url.replace("import", "status")
|
|
505
|
+
if "sdmxFile" in transfer_url:
|
|
506
|
+
transfer_url = transfer_url.replace("sdmxFile", "request")
|
|
507
|
+
elif "excel" in transfer_url:
|
|
508
|
+
transfer_url = transfer_url.replace("excel", "request")
|
|
509
|
+
|
|
510
|
+
#
|
|
511
|
+
response = requests.post(transfer_url, verify=True, headers=headers, data=payload)
|
|
512
|
+
except Exception as err:
|
|
513
|
+
returned_result = Client.__ERROR_OCCURRED + str(err)
|
|
514
|
+
return returned_result
|
|
515
|
+
else:
|
|
516
|
+
# If the response object cannot be converted to json, return an error
|
|
517
|
+
results_json = None
|
|
518
|
+
try:
|
|
519
|
+
results_json = json.loads(response.text)
|
|
520
|
+
if response.status_code == 200:
|
|
521
|
+
executionStatus = 'Execution status: ' + results_json['executionStatus']
|
|
522
|
+
if (results_json['executionStatus'] in [Client.__EXECUTION_IN_PROGRESS, Client.__EXECUTION_IN_QUEUED]
|
|
523
|
+
or Client.__CONNECTION_ABORTED in results_json['executionStatus']):
|
|
524
|
+
returned_result = results_json['executionStatus']
|
|
525
|
+
else:
|
|
526
|
+
returned_result = executionStatus + os.linesep + 'Outcome: ' + results_json['outcome'] + os.linesep
|
|
527
|
+
index = 0
|
|
528
|
+
while index < len(results_json['logs']):
|
|
529
|
+
returned_result = returned_result + 'Log' + str(index) + ': ' + results_json['logs'][index]['message'] + os.linesep
|
|
530
|
+
index += 1
|
|
531
|
+
else:
|
|
532
|
+
returned_result = Client.__ERROR_OCCURRED
|
|
533
|
+
if len(str(response.status_code)) > 0:
|
|
534
|
+
returned_result += 'Error code: ' + str(response.status_code) + os.linesep
|
|
535
|
+
if len(str(response.reason)) > 0:
|
|
536
|
+
returned_result += 'Reason: ' + str(response.reason) + os.linesep
|
|
537
|
+
if len(response.text) > 0:
|
|
538
|
+
returned_result += 'Text: ' + str(response.text)
|
|
539
|
+
|
|
540
|
+
returned_result += os.linesep
|
|
541
|
+
except ValueError as err:
|
|
542
|
+
returned_result = Client.__ERROR_OCCURRED
|
|
543
|
+
if len(str(response.status_code)) > 0:
|
|
544
|
+
returned_result += 'Error code: ' + str(response.status_code) + os.linesep
|
|
545
|
+
if len(str(response.reason)) > 0:
|
|
546
|
+
returned_result += 'Reason: ' + str(response.reason) + os.linesep
|
|
547
|
+
if len(response.text) > 0:
|
|
548
|
+
returned_result += 'Text: ' + str(response.text)
|
|
549
|
+
else:
|
|
550
|
+
returned_result += str(err)
|
|
551
|
+
returned_result += os.linesep
|
|
552
|
+
return returned_result
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
[tool.poetry]
|
|
2
|
+
name = "dotstat_io"
|
|
3
|
+
version = "1.0.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
|
+
setuptools = "^75.6.0"
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
[tool.poetry.group.dev.dependencies]
|
|
20
|
+
pytest = "^9.0.2"
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
[[tool.poetry.source]]
|
|
24
|
+
name = "fpho"
|
|
25
|
+
url = "https://files.pythonhosted.org"
|
|
26
|
+
priority = "primary"
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
[[tool.poetry.source]]
|
|
30
|
+
name = "PyPI"
|
|
31
|
+
priority = "primary"
|
|
32
|
+
|
|
33
|
+
[build-system]
|
|
34
|
+
requires = ["poetry-core"]
|
|
35
|
+
build-backend = "poetry.core.masonry.api"
|