sws-api-client 3.2.0__tar.gz → 3.3.0b1__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.
Files changed (32) hide show
  1. {sws_api_client-3.2.0 → sws_api_client-3.3.0b1}/PKG-INFO +1 -1
  2. {sws_api_client-3.2.0 → sws_api_client-3.3.0b1}/pyproject.toml +1 -1
  3. {sws_api_client-3.2.0 → sws_api_client-3.3.0b1}/sws_api_client/__init__.py +1 -1
  4. {sws_api_client-3.2.0 → sws_api_client-3.3.0b1}/sws_api_client/sws_api_client.py +22 -10
  5. {sws_api_client-3.2.0 → sws_api_client-3.3.0b1}/sws_api_client/tasks.py +6 -0
  6. sws_api_client-3.3.0b1/sws_api_client/token.py +93 -0
  7. sws_api_client-3.2.0/sws_api_client/token.py +0 -47
  8. {sws_api_client-3.2.0 → sws_api_client-3.3.0b1}/.gitignore +0 -0
  9. {sws_api_client-3.2.0 → sws_api_client-3.3.0b1}/LICENSE +0 -0
  10. {sws_api_client-3.2.0 → sws_api_client-3.3.0b1}/README.md +0 -0
  11. {sws_api_client-3.2.0 → sws_api_client-3.3.0b1}/sws_api_client/auth.py +0 -0
  12. {sws_api_client-3.2.0 → sws_api_client-3.3.0b1}/sws_api_client/campaign_manager.py +0 -0
  13. {sws_api_client-3.2.0 → sws_api_client-3.3.0b1}/sws_api_client/case_agnostic_model.py +0 -0
  14. {sws_api_client-3.2.0 → sws_api_client-3.3.0b1}/sws_api_client/codelist.py +0 -0
  15. {sws_api_client-3.2.0 → sws_api_client-3.3.0b1}/sws_api_client/data_retrieval.py +0 -0
  16. {sws_api_client-3.2.0 → sws_api_client-3.3.0b1}/sws_api_client/datasets.py +0 -0
  17. {sws_api_client-3.2.0 → sws_api_client-3.3.0b1}/sws_api_client/datatable.py +0 -0
  18. {sws_api_client-3.2.0 → sws_api_client-3.3.0b1}/sws_api_client/db.py +0 -0
  19. {sws_api_client-3.2.0 → sws_api_client-3.3.0b1}/sws_api_client/discover.py +0 -0
  20. {sws_api_client-3.2.0 → sws_api_client-3.3.0b1}/sws_api_client/files.py +0 -0
  21. {sws_api_client-3.2.0 → sws_api_client-3.3.0b1}/sws_api_client/flaglist.py +0 -0
  22. {sws_api_client-3.2.0 → sws_api_client-3.3.0b1}/sws_api_client/generic_models.py +0 -0
  23. {sws_api_client-3.2.0 → sws_api_client-3.3.0b1}/sws_api_client/harvester.py +0 -0
  24. {sws_api_client-3.2.0 → sws_api_client-3.3.0b1}/sws_api_client/mail.py +0 -0
  25. {sws_api_client-3.2.0 → sws_api_client-3.3.0b1}/sws_api_client/metadata_instances.py +0 -0
  26. {sws_api_client-3.2.0 → sws_api_client-3.3.0b1}/sws_api_client/plugins.py +0 -0
  27. {sws_api_client-3.2.0 → sws_api_client-3.3.0b1}/sws_api_client/s3.py +0 -0
  28. {sws_api_client-3.2.0 → sws_api_client-3.3.0b1}/sws_api_client/schemas/validation-report.schema.json +0 -0
  29. {sws_api_client-3.2.0 → sws_api_client-3.3.0b1}/sws_api_client/sessions.py +0 -0
  30. {sws_api_client-3.2.0 → sws_api_client-3.3.0b1}/sws_api_client/tags.py +0 -0
  31. {sws_api_client-3.2.0 → sws_api_client-3.3.0b1}/sws_api_client/users.py +0 -0
  32. {sws_api_client-3.2.0 → sws_api_client-3.3.0b1}/sws_api_client/validation_report.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: sws_api_client
3
- Version: 3.2.0
3
+ Version: 3.3.0-beta.1
4
4
  Author-email: Matteo <matteo.terrinoni@fao.org>, Daniele <daniele.mansillo@fao.org>, Luca <luca.simi@fao.org>
5
5
  License-File: LICENSE
6
6
  Requires-Python: <4,>=3.10
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "sws_api_client"
3
- version = "3.2.0"
3
+ version = "3.3.0-beta.1"
4
4
  description = ""
5
5
  authors = [
6
6
  { name = "Matteo", email = "matteo.terrinoni@fao.org" },
@@ -58,4 +58,4 @@ from .tasks import PluginPayload, TaskDataset, TaskInfo, TaskManager, TaskRespon
58
58
  from .users import User, Users
59
59
  from .harvester import Harvester
60
60
 
61
- __version__ = "3.2.0"
61
+ __version__ = "3.3.0-beta.1"
@@ -8,6 +8,7 @@ import argparse
8
8
  import json
9
9
  import logging
10
10
  import os
11
+ from collections.abc import Callable
11
12
 
12
13
  import boto3
13
14
 
@@ -45,6 +46,7 @@ class SwsApiClient:
45
46
  current_task_id: str | None,
46
47
  current_execution_id: str | None,
47
48
  object_cache: bool = False,
49
+ credentials_provider: Callable[[], AuthClientCredentials] | None = None,
48
50
  ) -> None:
49
51
  """Initialize the SWS API client.
50
52
 
@@ -55,17 +57,18 @@ class SwsApiClient:
55
57
  current_task_id: Optional current task identifier
56
58
  current_execution_id: Optional current execution identifier
57
59
  object_cache: Whether to enable client-side object caching (e.g. for codelists, dataset info)
60
+ credentials_provider: Optional callable returning fresh credentials, used to
61
+ retry a failed token request (e.g. after AWS rotates the secret)
58
62
  """
59
63
 
60
64
  logger.info("Initializing SwsApiClient")
61
65
 
62
66
  self.sws_endpoint = sws_endpoint
63
67
  self.sws_token = sws_token
64
- self.authclient = authclient
65
68
  self.current_task_id = current_task_id
66
69
  self.current_execution_id = current_execution_id
67
70
  self.object_cache = object_cache
68
- self.token = Token(authclient)
71
+ self.token = Token(authclient, credentials_provider)
69
72
  self.is_debug = self.check_debug()
70
73
  self.discoverable = Discover(
71
74
  sws_endpoint=sws_endpoint, sws_token=sws_token, token=self.token
@@ -74,6 +77,11 @@ class SwsApiClient:
74
77
  f"SwsApiClient initialized with endpoint {sws_endpoint} and token {sws_token}"
75
78
  )
76
79
 
80
+ @property
81
+ def authclient(self) -> AuthClientCredentials:
82
+ """Current auth credentials, updated when the secret is refreshed."""
83
+ return self.token.authclient
84
+
77
85
  @classmethod
78
86
  def __get_authclient_credentials_from_secret(
79
87
  cls, env_name="SWS_USER_CREDENTIALS_SECRET_NAME"
@@ -95,13 +103,12 @@ class SwsApiClient:
95
103
  raise ValueError(
96
104
  f"Secret name not found in environment variable: {env_name}"
97
105
  )
98
- cls.session = getattr(cls, "session", boto3.Session())
99
- cls.secret_manager = getattr(
100
- cls, "secret_manager", cls.session.client("secretsmanager")
101
- )
106
+ if not hasattr(cls, "secret_manager"):
107
+ cls.session = getattr(cls, "session", None) or boto3.Session()
108
+ cls.secret_manager = cls.session.client("secretsmanager")
102
109
  response = cls.secret_manager.get_secret_value(SecretId=secret_name)
103
110
  secretContent = json.loads(response["SecretString"])
104
- logger.debug(f"Secret fetched successfully: {secretContent}")
111
+ logger.debug(f"Secret {secret_name} fetched successfully")
105
112
  return AuthClientCredentials(
106
113
  clientId=secretContent["ID"],
107
114
  clientSecret=secretContent["SECRET"],
@@ -131,6 +138,7 @@ class SwsApiClient:
131
138
  logger.debug(
132
139
  f"Creating SwsApiClient from environment variables: {sws_endpoint_env}, {authclient_secret_name}"
133
140
  )
141
+ credentials_provider = None
134
142
  if (
135
143
  os.getenv("SWS_AUTH_CLIENTID")
136
144
  and os.getenv("SWS_AUTH_CLIENTSECRET")
@@ -148,11 +156,14 @@ class SwsApiClient:
148
156
  raise ValueError(
149
157
  f"You need ({authclient_secret_name}) or (SWS_AUTH_CLIENTID, SWS_AUTH_CLIENTSECRET, SWS_AUTH_SCOPE, SWS_AUTH_TOKENENDPOINT) environment variables to be set"
150
158
  )
151
- authclient: AuthClientCredentials = (
152
- cls.__get_authclient_credentials_from_secret(
159
+
160
+ # Re-read on token failures, so a secret rotated by AWS is picked up.
161
+ def credentials_provider() -> AuthClientCredentials:
162
+ return cls.__get_authclient_credentials_from_secret(
153
163
  env_name=authclient_secret_name
154
164
  )
155
- )
165
+
166
+ authclient = credentials_provider()
156
167
 
157
168
  sws_token = os.getenv("SWS_TOKEN")
158
169
  if not sws_token:
@@ -168,6 +179,7 @@ class SwsApiClient:
168
179
  current_task_id=os.getenv("TASK_ID"),
169
180
  current_execution_id=os.getenv("EXECUTION_ID"),
170
181
  authclient=authclient,
182
+ credentials_provider=credentials_provider,
171
183
  )
172
184
 
173
185
  @classmethod
@@ -335,6 +335,7 @@ class TaskManager:
335
335
  public: bool = False,
336
336
  retry: bool = False,
337
337
  emailNotification: bool = False,
338
+ max_duration: int | None = None,
338
339
  ) -> TaskCreateResponse:
339
340
  """Create a new plugin execution task.
340
341
 
@@ -351,6 +352,8 @@ class TaskManager:
351
352
  public: Whether task is public
352
353
  retry: Whether task can be retried
353
354
  emailNotification: Whether to send email notifications
355
+ max_duration: Max duration of the task in seconds; when None it is not sent
356
+ and the task manager's default applies
354
357
 
355
358
  Returns:
356
359
  TaskCreateResponse: Response containing the created task ID
@@ -398,6 +401,9 @@ class TaskManager:
398
401
  "parentTaskId": parentTaskId,
399
402
  }
400
403
 
404
+ if max_duration is not None:
405
+ data["config"]["maxDuration"] = max_duration
406
+
401
407
  if group:
402
408
  data["group"] = group
403
409
 
@@ -0,0 +1,93 @@
1
+ import datetime
2
+ import logging
3
+ import time
4
+ from collections.abc import Callable
5
+
6
+ import requests
7
+
8
+ from sws_api_client.auth import AuthClientCredentials
9
+ from sws_api_client.case_agnostic_model import CaseAgnosticModel
10
+
11
+ logger = logging.getLogger(__name__)
12
+
13
+ # Wait before each retry. The credentials are refreshed before every retry, so a
14
+ # secret rotated by AWS while a long task is running gets picked up.
15
+ RETRY_DELAYS_SECONDS = (30, 60)
16
+
17
+
18
+ class TokenModel(CaseAgnosticModel):
19
+ access_token: str
20
+ expires_on: datetime.datetime
21
+
22
+
23
+ class Token:
24
+ def __init__(
25
+ self,
26
+ authclient: AuthClientCredentials,
27
+ credentials_provider: Callable[[], AuthClientCredentials] | None = None,
28
+ ) -> None:
29
+ """
30
+ Args:
31
+ authclient: Credentials used to request access tokens.
32
+ credentials_provider: Optional callable returning fresh credentials. When set,
33
+ a failed token request is retried with credentials it returns.
34
+ """
35
+ self.authclient = authclient
36
+ self.credentials_provider = credentials_provider
37
+ self.token = self.get_token()
38
+
39
+ def get_token(self) -> TokenModel:
40
+ if (
41
+ hasattr(self, "token")
42
+ and self.token.expires_on is not None
43
+ and self.token.expires_on >= datetime.datetime.now()
44
+ ):
45
+ return self.token
46
+
47
+ try:
48
+ self.token = self._request_token()
49
+ except requests.RequestException:
50
+ if self.credentials_provider is None:
51
+ raise
52
+ self.token = self._retry_with_fresh_credentials()
53
+ logger.debug(f"New token, expires on {self.token.expires_on}")
54
+ return self.token
55
+
56
+ def _retry_with_fresh_credentials(self) -> TokenModel:
57
+ retries = len(RETRY_DELAYS_SECONDS)
58
+ for attempt, delay in enumerate(RETRY_DELAYS_SECONDS, start=1):
59
+ logger.warning(
60
+ "Token request failed, refreshing credentials and retrying in %ss (%s/%s)",
61
+ delay,
62
+ attempt,
63
+ retries,
64
+ )
65
+ time.sleep(delay)
66
+ self.authclient = self.credentials_provider()
67
+ try:
68
+ return self._request_token()
69
+ except requests.RequestException:
70
+ if attempt == retries:
71
+ logger.error(
72
+ "Token request still failing after %s retries, giving up",
73
+ retries,
74
+ )
75
+ raise
76
+
77
+ def _request_token(self) -> TokenModel:
78
+ response = requests.post(
79
+ self.authclient.token_endpoint,
80
+ data={
81
+ "grant_type": "client_credentials",
82
+ "client_id": self.authclient.client_id,
83
+ "client_secret": self.authclient.client_secret,
84
+ "scope": self.authclient.scope,
85
+ },
86
+ )
87
+ response.raise_for_status()
88
+ result = response.json()
89
+ return TokenModel(
90
+ access_token=result["access_token"],
91
+ expires_on=datetime.datetime.now()
92
+ + datetime.timedelta(seconds=result["expires_in"]),
93
+ )
@@ -1,47 +0,0 @@
1
- import datetime
2
- import logging
3
- import os
4
-
5
- import requests
6
-
7
- from sws_api_client.case_agnostic_model import CaseAgnosticModel
8
-
9
- logger = logging.getLogger(__name__)
10
- from sws_api_client.auth import AuthClientCredentials
11
-
12
-
13
- class TokenModel(CaseAgnosticModel):
14
- access_token: str
15
- expires_on: datetime.datetime
16
-
17
-
18
- class Token:
19
- def __init__(self, authclient: AuthClientCredentials) -> None:
20
- self.authclient = authclient
21
- self.token = self.get_token()
22
-
23
- def get_token(self) -> TokenModel:
24
- if (
25
- not hasattr(self, "token")
26
- or self.token.expires_on is None
27
- or self.token.expires_on < datetime.datetime.now()
28
- ):
29
- url = f"{self.authclient.tokenEndpoint}"
30
- data = {
31
- "grant_type": "client_credentials",
32
- "client_id": self.authclient.clientId,
33
- "client_secret": self.authclient.clientSecret,
34
- "scope": self.authclient.scope,
35
- }
36
- response = requests.post(url, data=data)
37
- response.raise_for_status()
38
- result = response.json()
39
- self.token = TokenModel(
40
- access_token=result["access_token"],
41
- expires_on=datetime.datetime.now()
42
- + datetime.timedelta(seconds=result["expires_in"]),
43
- )
44
- logger.debug(f"Token: {self.token}")
45
- return self.token
46
- else:
47
- return self.token
File without changes