statsuite-lib 0.1.3__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,66 @@
1
+ Metadata-Version: 2.3
2
+ Name: statsuite_lib
3
+ Version: 0.1.3
4
+ Summary:
5
+ Author: Sergio Pena
6
+ Author-email: isergiopena@gmail.com
7
+ Requires-Python: >=3.11,<4.0
8
+ Classifier: Programming Language :: Python :: 3
9
+ Classifier: Programming Language :: Python :: 3.11
10
+ Classifier: Programming Language :: Python :: 3.12
11
+ Classifier: Programming Language :: Python :: 3.13
12
+ Requires-Dist: httpx (>=0.28.1,<0.29.0)
13
+ Requires-Dist: pydantic (==2.9.2)
14
+ Description-Content-Type: text/markdown
15
+
16
+ # StatSuite-lib
17
+
18
+ StatSuite-lib is an opinionated library to interact with dotStatSuite components.
19
+ There is no full coverage of the APIs, it will be completed as needed.
20
+
21
+ ## Installation
22
+
23
+ Use the package manager [pip](https://pip.pypa.io/en/stable/) to install statsuite-lib.
24
+
25
+ ```bash
26
+ pip install statsuite-lib
27
+ ```
28
+
29
+ ## Usage
30
+
31
+ Check the documentation, mainly the statsuite-cli repo can serve as example on how to use this library
32
+ Keycloak client must be initialized and pass to any other client requiring authentication to manage the token creation and renewal
33
+
34
+ ```python
35
+ from statsuite_lib import SFSClient, KeycloakClient
36
+
37
+
38
+ OPENID_URL = "http://localhost:8080/auth/realms/demo/.well-known/openid-configuration"
39
+ KEYCLOAK_USER = 'test-admin'
40
+ KEYCLOAK_PASSWORD = 'admin'
41
+ SFS_API_KEY = 'secret'
42
+ SFS_URL = 'http://localhost:3004'
43
+
44
+ keycloak = KeycloakClient(openid_url=OPENID_URL,
45
+ username=KEYCLOAK_USER,
46
+ password=KEYCLOAK_PASSWORD)
47
+
48
+
49
+ sfs = SFSClient(sfs_url=SFS_URL, sfs_api_key=SFS_API_KEY)
50
+
51
+ id = sfs.index()
52
+
53
+ print(f"Index started with id {id}")
54
+ sfs.wait_for_reindex_to_finish(loading_id=id, tenant='default')
55
+ ```
56
+
57
+ ## Contributing
58
+
59
+ Pull requests are welcome. For major changes, please open an issue first
60
+ to discuss what you would like to change.
61
+
62
+ Please make sure to update tests as appropriate.
63
+
64
+ ## License
65
+
66
+ [MIT](https://choosealicense.com/licenses/mit/)
@@ -0,0 +1,51 @@
1
+ # StatSuite-lib
2
+
3
+ StatSuite-lib is an opinionated library to interact with dotStatSuite components.
4
+ There is no full coverage of the APIs, it will be completed as needed.
5
+
6
+ ## Installation
7
+
8
+ Use the package manager [pip](https://pip.pypa.io/en/stable/) to install statsuite-lib.
9
+
10
+ ```bash
11
+ pip install statsuite-lib
12
+ ```
13
+
14
+ ## Usage
15
+
16
+ Check the documentation, mainly the statsuite-cli repo can serve as example on how to use this library
17
+ Keycloak client must be initialized and pass to any other client requiring authentication to manage the token creation and renewal
18
+
19
+ ```python
20
+ from statsuite_lib import SFSClient, KeycloakClient
21
+
22
+
23
+ OPENID_URL = "http://localhost:8080/auth/realms/demo/.well-known/openid-configuration"
24
+ KEYCLOAK_USER = 'test-admin'
25
+ KEYCLOAK_PASSWORD = 'admin'
26
+ SFS_API_KEY = 'secret'
27
+ SFS_URL = 'http://localhost:3004'
28
+
29
+ keycloak = KeycloakClient(openid_url=OPENID_URL,
30
+ username=KEYCLOAK_USER,
31
+ password=KEYCLOAK_PASSWORD)
32
+
33
+
34
+ sfs = SFSClient(sfs_url=SFS_URL, sfs_api_key=SFS_API_KEY)
35
+
36
+ id = sfs.index()
37
+
38
+ print(f"Index started with id {id}")
39
+ sfs.wait_for_reindex_to_finish(loading_id=id, tenant='default')
40
+ ```
41
+
42
+ ## Contributing
43
+
44
+ Pull requests are welcome. For major changes, please open an issue first
45
+ to discuss what you would like to change.
46
+
47
+ Please make sure to update tests as appropriate.
48
+
49
+ ## License
50
+
51
+ [MIT](https://choosealicense.com/licenses/mit/)
@@ -0,0 +1,54 @@
1
+ [tool.poetry]
2
+ name = "statsuite_lib"
3
+ version = "0.1.3"
4
+ description = ""
5
+ authors = ["Sergio Pena <isergiopena@gmail.com>"]
6
+ readme = "README.md"
7
+
8
+ [tool.poetry.dependencies]
9
+ python = "^3.11,<4.0"
10
+ httpx = "^0.28.1"
11
+ pydantic = "2.9.2"
12
+
13
+ [tool.poetry.requires-plugins]
14
+ poetry-plugin-export = ">=1.8"
15
+
16
+ [tool.poetry.group.dev.dependencies]
17
+ pytest-httpx = "^0.35.0"
18
+ pytest = "^8.3.4"
19
+ pytest-coverage = "^0.0"
20
+ pytest-mock = "^3.14.0"
21
+ bandit = "^1.8.0"
22
+ flake8-simplify = "^0.21.0"
23
+ black = "^24.10.0"
24
+ flake8-variables-names = "^0.0.6"
25
+ flake8-functions-names = "^0.4.0"
26
+ flake8-cognitive-complexity = "^0.1.0"
27
+ flake8-expression-complexity = "^0.0.11"
28
+ flake8-docstrings-complete = "^1.4.1"
29
+ flake8-black = "^0.3.6"
30
+ flake8-bandit = "^4.1.1"
31
+ flake8-isort = "^6.1.1"
32
+ safety = "^3.2.14"
33
+ flake8-pyproject = "^1.2.3"
34
+ freezegun = "^1.5.3"
35
+ cryptography = ">=44.0.2"
36
+ sphinx = "^8.2.3"
37
+ pdoc = "^15.0.4"
38
+ sphinx-rtd-theme = "^3.0.2"
39
+ myst-parser = "^4.0.1"
40
+ sphinx-toolbox = "^4.0.0"
41
+ pip = "^25.2"
42
+
43
+ [build-system]
44
+ requires = ["poetry-core"]
45
+ build-backend = "poetry.core.masonry.api"
46
+
47
+ [tool.flake8]
48
+ exclude = ['.poetry']
49
+ ignore = ['E231', 'E241']
50
+ per-file-ignores = [
51
+ '__init__.py:F401',
52
+ 'tests/*py:S101',
53
+ ]
54
+ max-line-length = 95
@@ -0,0 +1,6 @@
1
+ from .auth import AuthClient
2
+ from .config import ConfigClient
3
+ from .keycloak import KeycloakClient
4
+ from .nsi import NSIClient
5
+ from .sfs import SFSClient
6
+ from .transfer import TransferClient
@@ -0,0 +1 @@
1
+ from .auth import AuthClient
@@ -0,0 +1,96 @@
1
+ import logging
2
+
3
+ import httpx
4
+
5
+ from ..keycloak.keycloak import KeycloakClient
6
+
7
+
8
+ class AuthClient:
9
+ """A client for managing authorization rules through the Auth API.
10
+
11
+ This client handles the communication with the authorization service,
12
+ allowing for the management of access rules and permissions.
13
+
14
+ Attributes:
15
+ AUTH_URL (str): The complete URL for the Auth API including version.
16
+
17
+ Args:
18
+ auth_url (str): Base URL of the authorization service.
19
+ keycloak_client (KeycloakClient): Client for handling Keycloak authentication.
20
+ api_version (str, optional): API version to use. Defaults to "1.1".
21
+ """
22
+
23
+ def __init__(
24
+ self, auth_url: str, keycloak_client: KeycloakClient, api_version: str = "1.1"
25
+ ) -> None:
26
+ """Initialize the AuthClient.
27
+
28
+ Creates a new instance of the AuthClient with the specified configuration.
29
+ Sets up an HTTP client and configures logging.
30
+
31
+ Args:
32
+ auth_url (str): Base URL of the authorization service endpoint.
33
+ Should not include the version number.
34
+ keycloak_client (KeycloakClient): An initialized Keycloak client instance
35
+ used for authentication headers.
36
+ api_version (str, optional): API version string to use in URL construction.
37
+ Defaults to "1.1".
38
+
39
+ Example:
40
+ keycloak_client = KeycloakClient(...)
41
+ auth_client = AuthClient(
42
+ auth_url="https://auth.example.com",
43
+ keycloak_client=keycloak_client
44
+ )
45
+ """
46
+
47
+ self._client = httpx.Client()
48
+ self.AUTH_URL = f"{auth_url}/{api_version}"
49
+ self._keycloak_client = keycloak_client
50
+ self._log = logging.getLogger("AuthClient")
51
+
52
+ def add_rule(
53
+ self,
54
+ user_mask: str,
55
+ is_group: bool,
56
+ permission=int,
57
+ dataspace: str = "*",
58
+ artifact_type: int = 0,
59
+ artefact_agency_id: str = "*",
60
+ artefact_id: str = "*",
61
+ artefact_version: str = "*",
62
+ ):
63
+ """Add a new authorization rule to the system.
64
+
65
+ Args:
66
+ user_mask (str): The user or group identifier pattern.
67
+ is_group (bool): Whether the rule applies to a group (True) or user (False).
68
+ permission (int): The permission level to grant.
69
+ dataspace (str, optional): Target dataspace. Defaults to "*" (all dataspaces).
70
+ artifact_type (int, optional): Type of artifact. Defaults to 0.
71
+ artefact_agency_id (str, optional): Agency ID of the artifact. Defaults to "*".
72
+ artefact_id (str, optional): ID of the artifact. Defaults to "*".
73
+ artefact_version (str, optional): Version of the artifact. Defaults to "*".
74
+
75
+ Returns:
76
+ dict: The JSON response from the server containing the created rule.
77
+
78
+ """
79
+
80
+ data = {
81
+ "userMask": user_mask,
82
+ "isGroup": is_group,
83
+ "dataSpace": dataspace,
84
+ "artefactType": artifact_type,
85
+ "artefactAgencyId": artefact_agency_id,
86
+ "artefactId": artefact_id,
87
+ "artefactVersion": artefact_version,
88
+ "permission": permission,
89
+ }
90
+
91
+ url = f"{self.AUTH_URL}/AuthorizationRules"
92
+ response = httpx.post(
93
+ url=url, headers=self._keycloak_client.auth_header(), data=data
94
+ )
95
+ response.raise_for_status()
96
+ return response.json()
@@ -0,0 +1 @@
1
+ from .config import ConfigClient
@@ -0,0 +1,47 @@
1
+ import logging
2
+ from typing import Iterator
3
+
4
+ import httpx
5
+
6
+ from .models import Space, Tenants
7
+
8
+
9
+ class ConfigClient:
10
+ """Client for the SDMX Faceted search service"""
11
+
12
+ def __init__(self, config_url: str) -> None:
13
+ """Inits the client
14
+
15
+ Args:
16
+ config_url: Endpoint url for Config service.
17
+ """
18
+
19
+ self._client = httpx.Client()
20
+ self.CONFIG_URL = config_url
21
+ self.log = logging.getLogger("ConfigClient")
22
+ self.log.level = logging.INFO
23
+
24
+ def get_tenants(self) -> str:
25
+ """Gets tenants config
26
+
27
+ Returns:
28
+ loadingId(str)
29
+ """
30
+ resp = httpx.get(f"{self.CONFIG_URL}/configs/tenants.json")
31
+ if resp.status_code == 200:
32
+ loading = Tenants.model_validate(resp.json())
33
+ return loading
34
+
35
+ def get_dataspaces(self, tenant: str = "default") -> Iterator[Space]:
36
+ """Returns a list of dataspaces configured for a tenant
37
+
38
+ Args:
39
+ tenant: select which tenant
40
+
41
+ Yields:
42
+ Space: A dataspace configuration object for each space in the tenant.
43
+ """
44
+ tenants = self.get_tenants()
45
+ spaces = tenants.root.get(tenant).spaces
46
+ for space in spaces:
47
+ yield spaces.get(space)
@@ -0,0 +1,49 @@
1
+ from typing import Dict, Union
2
+
3
+ from pydantic import BaseModel, ConfigDict, RootModel
4
+
5
+
6
+ class Index(RootModel):
7
+ """Model for return json from indexing request on SFS
8
+
9
+ Attributes:
10
+ root: returns a json in the format { "loadingId": ######### }
11
+ """
12
+
13
+ root: Dict[str, Union[str, Dict]]
14
+
15
+
16
+ class Space(BaseModel):
17
+ """Model for spaces inside tenants
18
+
19
+ Attributes:
20
+ id: Space id
21
+ url: space url
22
+ """
23
+
24
+ id: str # noqa VNE003
25
+ url: str
26
+
27
+
28
+ class Tenant(BaseModel):
29
+ """Minimun model for Loading Log entity
30
+
31
+ Attributes:
32
+ model_config: Configuration
33
+ id: Loadingid
34
+ spaces: Dict of spaces inside tenant
35
+ """
36
+
37
+ model_config = ConfigDict(extra="allow")
38
+ id: str # noqa VNE003
39
+ spaces: Dict[str, Space]
40
+
41
+
42
+ class Tenants(RootModel):
43
+ """Collection of loading log entries
44
+
45
+ Attributes:
46
+ root: Collection of Loadings without key
47
+ """
48
+
49
+ root: Dict[str, Tenant]
@@ -0,0 +1 @@
1
+ from .keycloak import KeycloakClient
@@ -0,0 +1,147 @@
1
+ import datetime
2
+ import logging
3
+
4
+ import httpx
5
+
6
+
7
+ class KeycloakClient:
8
+ """
9
+ A client for handling Keycloak authentication and token management.
10
+
11
+ This class manages OAuth2/OpenID Connect authentication with Keycloak,
12
+ including token acquisition, refresh, and management.
13
+
14
+ Args:
15
+ openid_url (str): The OpenID configuration URL for the Keycloak server
16
+ username (str): Username for authentication
17
+ password (str): Password for authentication
18
+ """
19
+
20
+ def __init__(self, openid_url: str, username: str, password: str) -> None:
21
+ """
22
+ Initialize the KeycloakClient with authentication credentials.
23
+
24
+ Args:
25
+ openid_url (str): The OpenID configuration URL for the Keycloak server
26
+ username (str): Username for authentication
27
+ password (str): Password for authentication
28
+ """
29
+
30
+ self._client = httpx.Client()
31
+ self.OPENID_URL = openid_url
32
+ self.log = logging.getLogger("KeycloakClient")
33
+ self._auth_endpoint = None
34
+ self._token_endpoint = None
35
+ self.access_token = None
36
+ self.access_token_expires = None
37
+ self.refresh_token = None
38
+ self._get_openid_configuration()
39
+ self._authenticate(username=username, password=password)
40
+
41
+ def _get_openid_configuration(self) -> None:
42
+ """
43
+ Retrieve OpenID Connect configuration from Keycloak server.
44
+
45
+ This method fetches the authorization and token endpoints from the
46
+ Keycloak OpenID configuration.
47
+
48
+ Raises:
49
+ ConnectError: If connection to the Keycloak server fails.
50
+ """
51
+ self.log.info(f"Getting openid configuration from {self.OPENID_URL}")
52
+ try:
53
+
54
+ response = self._client.get(self.OPENID_URL)
55
+ self._auth_endpoint = response.json()["authorization_endpoint"]
56
+ self._token_endpoint = response.json()["token_endpoint"]
57
+
58
+ except httpx.ConnectError as e:
59
+ raise httpx.ConnectError(f"Failed to get openid configuration: {e}")
60
+
61
+ def _authenticate(self, username: str, password: str) -> None:
62
+ """
63
+ Perform initial authentication with Keycloak using username and password.
64
+
65
+ This method obtains the initial access and refresh tokens using the
66
+ provided credentials.
67
+
68
+ Args:
69
+ username (str): Username for authentication
70
+ password (str): Password for authentication
71
+
72
+
73
+ """
74
+
75
+ self.log.info(f"Authenticating with {self._auth_endpoint}")
76
+ response = self._client.post(
77
+ self._token_endpoint,
78
+ data={
79
+ "grant_type": "password",
80
+ "client_id": "stat-suite",
81
+ "username": username,
82
+ "password": password,
83
+ },
84
+ )
85
+
86
+ self.refresh_token = response.json()["refresh_token"]
87
+ self.access_token = response.json()["access_token"]
88
+ self.access_token_expires = datetime.datetime.now() + datetime.timedelta(
89
+ seconds=response.json()["expires_in"]
90
+ )
91
+
92
+ def trigger_refresh_token(self) -> None:
93
+ """
94
+ Refresh the access token using the refresh token.
95
+
96
+ This method is called when the access token is expired or about to expire.
97
+ It uses the refresh token to obtain a new access token and refresh token pair.
98
+
99
+
100
+ """
101
+
102
+ self.log.info("Triggering refresh token")
103
+ response = self._client.post(
104
+ self._token_endpoint,
105
+ data={
106
+ "grant_type": "refresh_token",
107
+ "client_id": "stat-suite",
108
+ "refresh_token": self.refresh_token,
109
+ },
110
+ )
111
+ self.access_token = response.json()["access_token"]
112
+ self.access_token_expires = datetime.datetime.now() + datetime.timedelta(
113
+ seconds=response.json()["expires_in"]
114
+ )
115
+ self.refresh_token = response.json()["refresh_token"]
116
+
117
+ def get_access_token(self) -> str:
118
+ """
119
+ Get the current valid access token.
120
+
121
+ This method checks if the current access token is valid and not expired.
122
+ If expired, it automatically triggers a token refresh.
123
+
124
+ Returns:
125
+ Access token string
126
+ """
127
+
128
+ self.log.info("Getting access token")
129
+ if (
130
+ self.access_token_expires is None
131
+ or self.access_token_expires < datetime.datetime.now() # noqa W503
132
+ ):
133
+ self.trigger_refresh_token()
134
+
135
+ return self.access_token
136
+
137
+ def auth_header(self) -> dict:
138
+ """
139
+ Create an authorization header using the current access token.
140
+
141
+ Returns:
142
+ dict: A dictionary containing the Authorization header with the Bearer token
143
+ in the format {'Authorization': 'Bearer <token>'}
144
+
145
+ """
146
+
147
+ return {"Authorization": f"Bearer {self.get_access_token()}"}
@@ -0,0 +1 @@
1
+ from .nsi import NSIClient
@@ -0,0 +1,101 @@
1
+ import logging
2
+
3
+ import httpx
4
+
5
+ from ..keycloak.keycloak import KeycloakClient
6
+
7
+
8
+ class NSIClient:
9
+ """Client for interacting with the NSI (Network Service Interface) API.
10
+
11
+ This client handles file operations (upload, download, delete) through the NSI service
12
+ with authentication handled by a Keycloak client.
13
+
14
+ Attributes:
15
+ NSI_URL (str): Base URL for the NSI service.
16
+ log: Logger instance for the NSIClient.
17
+
18
+ Args:
19
+ nsi_url (str): Base URL of the NSI service.
20
+ keycloak_client (KeycloakClient): Client for handling Keycloak authentication.
21
+ """
22
+
23
+ def __init__(self, nsi_url: str, keycloak_client: KeycloakClient) -> None:
24
+ """Initialize the NSIClient.
25
+
26
+ Args:
27
+ nsi_url (str): Base URL of the NSI service.
28
+ keycloak_client (KeycloakClient): Initialized Keycloak client for authentication.
29
+ """
30
+ self._client = httpx.Client()
31
+ self.NSI_URL = nsi_url
32
+ self._keycloak_client = keycloak_client
33
+ self.log = logging.getLogger("NSIClient")
34
+
35
+ def put(self, file_to_upload, path: str, timeout: int = None) -> int:
36
+ """Upload a file to the NSI service.
37
+
38
+ Args:
39
+ file_to_upload: File content to upload.
40
+ path (str): Target path on the NSI service.
41
+ timeout (int, optional): Request timeout in seconds. Defaults to None.
42
+
43
+ Returns:
44
+ int: HTTP status code of the upload response.
45
+
46
+ """
47
+
48
+ headers = self._keycloak_client.auth_header() | {
49
+ "Content-Type": "application/x-www-form-urlencoded"
50
+ }
51
+
52
+ self.log.info(f"Uploading to NSI: {self.NSI_URL + path}")
53
+
54
+ response = httpx.post(
55
+ self.NSI_URL + path,
56
+ content=file_to_upload,
57
+ headers=headers,
58
+ timeout=timeout,
59
+ )
60
+
61
+ if response.status_code != 207:
62
+ self.log.info(f"NSI response: {response.text}")
63
+ self.log.info(response.text)
64
+ response.raise_for_status()
65
+ return response.status_code
66
+
67
+ def get(self, path: str, headers: dict = {}, timeout: int = None) -> httpx.Response:
68
+ """Retrieve a file or resource from the NSI service.
69
+
70
+ Args:
71
+ path (str): Path to the resource on the NSI service.
72
+ headers (dict, optional): Additional HTTP headers to include. Defaults to {}.
73
+ timeout (int, optional): Request timeout in seconds. Defaults to None.
74
+
75
+ Returns:
76
+ httpx.Response: Response object containing the requested resource.
77
+ """
78
+
79
+ headers |= self._keycloak_client.auth_header()
80
+ self.log.info(f"Getting from NSI: {self.NSI_URL + path}")
81
+ resp = httpx.get(self.NSI_URL + path, headers=headers, timeout=timeout)
82
+ resp.raise_for_status()
83
+ return resp
84
+
85
+ def delete(self, path: str, timeout: int = None) -> int:
86
+ """Delete a file or resource from the NSI service.
87
+
88
+ Args:
89
+ path (str): Path to the resource to delete on the NSI service.
90
+ timeout (int, optional): Request timeout in seconds. Defaults to None.
91
+
92
+ Returns:
93
+ int: HTTP status code of the delete response.
94
+
95
+ """
96
+
97
+ headers = self._keycloak_client.auth_header()
98
+ self.log.info(f"Deleting from NSI: {self.NSI_URL + path}")
99
+ response = httpx.delete(self.NSI_URL + path, headers=headers, timeout=timeout)
100
+ response.raise_for_status()
101
+ return response.status_code
@@ -0,0 +1 @@
1
+ from .sfs import SFSClient
@@ -0,0 +1,39 @@
1
+ from typing import Dict, List, Optional
2
+
3
+ from pydantic import BaseModel, ConfigDict, RootModel
4
+
5
+
6
+ class Index(RootModel):
7
+ """Model for return json from indexing request on SFS
8
+
9
+ Attributes:
10
+ root: returns a json in the format { "loadingId": ######### }
11
+ """
12
+
13
+ root: Dict[str, int]
14
+
15
+
16
+ class LoadingLog(BaseModel):
17
+ """Minimun model for Loading Log entity
18
+
19
+ Attributes:
20
+ model_config: Configuration
21
+ executionStart: Timestamp start of the task
22
+ executionStatus: Status of the task
23
+ id: Loadingid
24
+ """
25
+
26
+ model_config = ConfigDict(extra="allow")
27
+ executionStart: str
28
+ executionStatus: Optional[str] = None
29
+ id: int # noqa
30
+
31
+
32
+ class LoadingLogs(RootModel):
33
+ """Collection of loading log entries
34
+
35
+ Attributes:
36
+ root: Collection of Loadings without key
37
+ """
38
+
39
+ root: List[LoadingLog]
@@ -0,0 +1,148 @@
1
+ import logging
2
+ import time
3
+ from enum import IntEnum
4
+ from typing import Optional
5
+
6
+ import httpx
7
+
8
+ from .models import LoadingLog, LoadingLogs
9
+
10
+
11
+ class SFSClient:
12
+ """Client for the SDMX Faceted search service"""
13
+
14
+ def __init__(self, sfs_url: str, sfs_api_key: str) -> None:
15
+ """Inits the client
16
+
17
+ Args:
18
+ sfs_url: Endpoint url for SFS service.
19
+ sfs_api_key: API key for the SFS service
20
+ """
21
+
22
+ self._client = httpx.Client()
23
+ self.SFS_URL = sfs_url
24
+ self._sfs_api_key = sfs_api_key
25
+ self.log = logging.getLogger("SFSClient")
26
+ self.log.level = logging.INFO
27
+
28
+ def index(self, tenant: str = "default") -> str:
29
+ """Triggers a new indexing task
30
+
31
+ Args:
32
+ tenant: (str) tenant to trigger the index for
33
+
34
+ Returns:
35
+ loadingId(str)
36
+ """
37
+ resp = httpx.post(
38
+ f"{self.SFS_URL}/admin/dataflows?api-key={self._sfs_api_key}&tenant={tenant}" # noqa
39
+ )
40
+ if resp.status_code == 200:
41
+ from .models import Index
42
+
43
+ loading = Index.model_validate(resp.json())
44
+ return loading.root.get("loadingId")
45
+
46
+ class LoadingStatus(IntEnum):
47
+ """Enum class to represent status of the loading tasks
48
+
49
+ Attributes:
50
+ COMPLETED: Task finished succesfully
51
+ RETRY: Some recoverable error happened or the task is in a non-complete
52
+ status
53
+ FAILED: Unrecoverable error
54
+ BUG502: There si a bug retrieving loading status that can be retrieve
55
+ fetching the logs without id query param
56
+ """
57
+
58
+ COMPLETED = 1
59
+ RETRY = 2
60
+ FAILED = 3
61
+ BUG502 = 10
62
+
63
+ def get_log(self, tenant: str, loading_id: str) -> Optional[LoadingLog]:
64
+ """Get log and status from by loading_id, if retrieving a log by id
65
+ fails with a 502 it will fallback to retrieve all available logs and
66
+ filter them
67
+
68
+ Arguments:
69
+ tenant: (str) .stat tenant
70
+ loading_id: (str) id of the loading
71
+
72
+ Returns:
73
+ LoadingLog or None if the loading_id cannot be found
74
+ """
75
+
76
+ resp = httpx.get(
77
+ url=f"{self.SFS_URL}/admin/logs?api-key={self._sfs_api_key}&tenant={tenant}"
78
+ )
79
+ if resp.status_code == 200:
80
+ return LoadingLog.model_validate(resp.json())
81
+ if resp.status_code == 502:
82
+ self.log.error("Error 502 getting loading log, using expensive query")
83
+ resp = httpx.get(
84
+ f"{self.SFS_URL}/admin/logs?api-key={self._sfs_api_key}&tenant={tenant}"
85
+ ) # noqa
86
+ loadings = LoadingLogs.model_validate(resp.json())
87
+ for loading in loadings.root:
88
+ if str(loading.id) == loading_id:
89
+ return loading
90
+ self.log.error(f"Error gathering logs {resp.text}")
91
+
92
+ def check_status_loading(self, tenant: str, loading_id: str) -> LoadingStatus:
93
+ """Check the status of a loading taks
94
+
95
+ Arguments:
96
+ tenant: (str) .stat tenant
97
+ loading_id: (str) id of the loading
98
+
99
+ Returns:
100
+ LoadingStatus enumeration
101
+ """
102
+ loading = self.get_log(tenant=tenant, loading_id=loading_id)
103
+ if loading.executionStatus == "completed":
104
+ return self.LoadingStatus.COMPLETED
105
+ else:
106
+ self.log.error(f"Mapping outcome of {loading.executionStatus} to RETRY")
107
+ return self.LoadingStatus.RETRY
108
+
109
+ def wait_for_index_to_finish( # noqa FNE005
110
+ self,
111
+ tenant: str,
112
+ loading_id: str,
113
+ startup_sleep: int = 0,
114
+ timeout: int = 600,
115
+ backoff: int = 30,
116
+ ) -> bool:
117
+ """This method will periodically check the status of a loading task and
118
+ until it finishes, error or expires the timeout
119
+
120
+ Arguments:
121
+ tenant: (str) .stat tenant
122
+ loading_id: (str) id of the loading
123
+ startup_sleep: (int) grace period (secs) before start fetching the
124
+ loading state
125
+ timeout: (int) max time (secs) the loop will be running
126
+ backoff: (int) time between iterations fetching the data
127
+
128
+ Returns:
129
+ boolean stating if the loading task finished correctly
130
+ """
131
+ start = time.time()
132
+ time.sleep(startup_sleep)
133
+
134
+ self.log.info("STARTING")
135
+ while True:
136
+ status = self.check_status_loading(tenant=tenant, loading_id=loading_id)
137
+ if status == self.LoadingStatus.COMPLETED:
138
+ return True
139
+
140
+ if time.time() - start > timeout:
141
+ self.log.error(
142
+ f"Timeout waiting for dataflows to be indexed {timeout} seconds passed" # noqa
143
+ )
144
+ return False
145
+ self.log.error(
146
+ "Still not information retrieved about the loading, waiting..."
147
+ )
148
+ time.sleep(backoff)
@@ -0,0 +1 @@
1
+ from .transfer import TransferClient
@@ -0,0 +1,218 @@
1
+ import logging
2
+ import time
3
+
4
+ import httpx
5
+
6
+ from statsuite_lib import KeycloakClient
7
+
8
+
9
+ class TransferClient:
10
+ """
11
+ A client for handling SDMX file transfers and dataflow operations.
12
+
13
+ Args:
14
+ transfer_url (str): Base URL for the transfer service
15
+ keycloak_client (KeycloakClient): Authentication client instance
16
+ api_version (str, optional): API version to use. Defaults to '3'
17
+ """
18
+
19
+ def __init__(
20
+ self, transfer_url: str, keycloak_client: KeycloakClient, api_version: str = "3"
21
+ ) -> None:
22
+ """
23
+ Initialize the TransferClient.
24
+
25
+ Args:
26
+ transfer_url (str): Base URL for the transfer service
27
+ keycloak_client (KeycloakClient): Authentication client instance
28
+ api_version (str, optional): API version to use. Defaults to '3'
29
+ """
30
+ self._client = httpx.Client()
31
+ self.TRANSFER_URL = f"{transfer_url}/{api_version}"
32
+ self._keycloak_client = keycloak_client
33
+ self._log = logging.getLogger("TransferClient")
34
+
35
+ def import_sdmx_file(
36
+ self,
37
+ file_object,
38
+ dataspace: str,
39
+ target_version: int = 0,
40
+ restoration_option_required: bool = False,
41
+ validation_type: int = 1,
42
+ timeout: int = None,
43
+ ) -> int:
44
+ """
45
+ Import an SDMX file into the specified dataspace.
46
+
47
+ Args:
48
+ file_object: The SDMX file to import
49
+ dataspace (str): Target dataspace name
50
+ target_version (int, optional): Version number for the import. Defaults to 0
51
+ restoration_option_required (bool, optional): Whether restoration is required. Defaults to False # noqa E501
52
+ validation_type (int, optional): Type of validation to perform. Defaults to 1
53
+ timeout (int, optional): Request timeout in seconds. Defaults to None
54
+
55
+ Returns:
56
+ int: The ID of the import request
57
+
58
+ Raises:
59
+ httpx.HTTPError: If the request fails
60
+
61
+ Example:
62
+ for csv_file in sample_data_dir.rglob("*.csv"):
63
+ with open(csv_file, 'rb') as file:
64
+ id = transfer.import_sdmx_file(file=file, dataspace='design')
65
+ """
66
+ data = {
67
+ "dataspace": dataspace,
68
+ "targetVersion": target_version,
69
+ "restorationOptionRequired": restoration_option_required,
70
+ "validationType": validation_type,
71
+ }
72
+ files = {"file": file_object}
73
+ url = f"{self.TRANSFER_URL}/import/sdmxFile"
74
+ resp = httpx.post(
75
+ url=url,
76
+ headers=self._keycloak_client.auth_header(),
77
+ data=data,
78
+ files=files,
79
+ timeout=timeout,
80
+ )
81
+ return resp.json().get("message").split(" ")[2]
82
+
83
+ def check_request_status(self, dataspace: str, id: int) -> str: # noqa VNE003
84
+ """
85
+ Check the status of a request for a given dataspace and ID.
86
+
87
+ Args:
88
+ dataspace (str): The dataspace name
89
+ id (int): The request ID to check
90
+
91
+ Returns:
92
+ str: The execution status of the request
93
+
94
+ """
95
+ self._log.info(f"Checking request status for dataspace {dataspace} and id {id}")
96
+ data = {"dataspace": dataspace, "id": id}
97
+ resp = httpx.post(
98
+ url=f"{self.TRANSFER_URL}/status/request",
99
+ headers=self._keycloak_client.auth_header(),
100
+ data=data,
101
+ )
102
+ return resp.json().get("executionStatus")
103
+
104
+ def wait_for_request(
105
+ self,
106
+ dataspace: str,
107
+ id: int, # noqa VNE003
108
+ timeout: int = 300,
109
+ backoff: int = 30, # noqa VNE003
110
+ ) -> None:
111
+ """
112
+ Wait for a request to complete with timeout and backoff mechanism.
113
+
114
+ Args:
115
+ dataspace (str): The dataspace name
116
+ id (int): The request ID to wait for
117
+ timeout (int, optional): Maximum time to wait in seconds. Defaults to 300
118
+ backoff (int, optional): Time between status checks in seconds. Defaults to 30
119
+
120
+ Returns:
121
+ bool: True if timeout occurred, False if request completed successfully
122
+
123
+
124
+ """
125
+ start = time.time()
126
+ while True:
127
+ status = self.check_request_status(dataspace=dataspace, id=id)
128
+ if status == "Completed":
129
+ return False
130
+
131
+ if time.time() - start > timeout:
132
+ self._log.error(
133
+ f"Timeout waiting for request to be completed {timeout} seconds passed"
134
+ )
135
+ return True
136
+ time.sleep(backoff)
137
+
138
+ def transfer_dataflow(
139
+ self, source_dataspace: str, destination_dataspace: str, dataflow: str
140
+ ):
141
+ """
142
+ Transfer a dataflow from source dataspace to destination dataspace.
143
+
144
+ Args:
145
+ source_dataspace (str): Source dataspace name
146
+ destination_dataspace (str): Destination dataspace name
147
+ dataflow (str): Name of the dataflow to transfer
148
+
149
+ Returns:
150
+ str: The ID of the transfer request
151
+
152
+
153
+ """
154
+ self._log.info(
155
+ f"Transferring dataflow {dataflow} from {source_dataspace} to {destination_dataspace}" # noqa E501
156
+ )
157
+ data = {
158
+ "sourceDataspace": source_dataspace,
159
+ "destinationDataspace": destination_dataspace,
160
+ "sourceDataflow": dataflow,
161
+ "destinationDataflow": dataflow,
162
+ "transferContent": 0,
163
+ "sourceVersion": 0,
164
+ "targetVersion": 0,
165
+ "restorationOptionRequired": False,
166
+ "validationType": 0,
167
+ }
168
+
169
+ resp = httpx.post(
170
+ url=f"{self.TRANSFER_URL}/transfer/dataflow",
171
+ headers=self._keycloak_client.auth_header(),
172
+ data=data,
173
+ )
174
+ return resp.json().get("message").split(" ")[2]
175
+
176
+ def get_tune(self, dataspace: str, dsd_id: str):
177
+ """
178
+ Retrieve tune information for a specific DSD in a dataspace.
179
+
180
+ Args:
181
+ dataspace (str): The dataspace name
182
+ dsd_id (str): The ID of the Data Structure Definition
183
+
184
+ Returns:
185
+ dict: Tune information for the specified DSD
186
+
187
+ """
188
+ self._log.info(f"Getting DSD {dsd_id} tune information in ds {dataspace}")
189
+ data = {"dataspace": dataspace, "dsd": dsd_id}
190
+ resp = httpx.post(
191
+ url=f"{self.TRANSFER_URL}/tune/info",
192
+ headers=self._keycloak_client.auth_header(),
193
+ data=data,
194
+ )
195
+ return resp.json()
196
+
197
+ def set_tune(self, dataspace: str, dsd_id: str, index_type: int):
198
+ """
199
+ Set tune parameters for a specific DSD in a dataspace.
200
+
201
+ Args:
202
+ dataspace (str): The dataspace name
203
+ dsd_id (str): The ID of the Data Structure Definition
204
+ index_type (int): The type of index to set
205
+
206
+ Returns:
207
+ dict: Response containing the result of the tune operation
208
+
209
+
210
+ """
211
+ self._log.info(f"Getting DSD {dsd_id} tune information in ds {dataspace}")
212
+ data = {"dataspace": dataspace, "dsd": dsd_id, "indexType": index_type}
213
+ resp = httpx.post(
214
+ url=f"{self.TRANSFER_URL}/tune/dsd",
215
+ headers=self._keycloak_client.auth_header(),
216
+ data=data,
217
+ )
218
+ return resp.json()