seer-pas-sdk 0.3.4__py3-none-any.whl → 1.1.0__py3-none-any.whl

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.
seer_pas_sdk/auth/auth.py CHANGED
@@ -1,5 +1,9 @@
1
+ import atexit
2
+ from datetime import datetime
1
3
  import requests
2
4
  import jwt
5
+ from ..common import get_version
6
+ from ..common.errors import ServerError
3
7
 
4
8
 
5
9
  class Auth:
@@ -24,6 +28,7 @@ class Auth:
24
28
 
25
29
  self.username = username
26
30
  self.__password = password
31
+ self.version = get_version()
27
32
 
28
33
  if instance not in Auth._instances:
29
34
  if instance.startswith("https://") or instance.startswith(
@@ -35,7 +40,10 @@ class Auth:
35
40
  raise ValueError("Invalid PAS instance.")
36
41
  else:
37
42
  self.url = Auth._instances[instance]
38
-
43
+ self.refresh_token = None
44
+ self.id_token = None
45
+ self.access_token = None
46
+ self.refresh_token_expiry = 0
39
47
  self.instance = instance
40
48
 
41
49
  # Null initialize multi tenant attributes
@@ -46,7 +54,9 @@ class Auth:
46
54
  self.active_role,
47
55
  ) = [None] * 4
48
56
 
49
- def login(self):
57
+ atexit.register(self._logout)
58
+
59
+ def _login(self):
50
60
  """
51
61
  Logs into the PAS instance using the mapped URL and the login credentials (username and password) provided in the constructor.
52
62
 
@@ -55,7 +65,11 @@ class Auth:
55
65
  dict
56
66
  A dictionary containing the login response from the PAS instance.
57
67
  """
58
- response = requests.post(
68
+ s = requests.Session()
69
+ s.headers.update(
70
+ {"x-seer-source": "sdk", "x-seer-id": f"{self.version}/login"}
71
+ )
72
+ response = s.post(
59
73
  f"{self.url}auth/login",
60
74
  json={"username": self.username, "password": self.__password},
61
75
  )
@@ -65,35 +79,180 @@ class Auth:
65
79
  "Check if the credentials are correct or if the backend is running or not."
66
80
  )
67
81
 
82
+ try:
83
+ response.raise_for_status()
84
+ except Exception as e:
85
+ raise ServerError("Could not login to the PAS instance")
86
+
87
+ res = response.json()
88
+ mfa_challenge = res.get("challenge", None)
89
+ if mfa_challenge:
90
+ mfa_args = {}
91
+ if any(
92
+ x not in res
93
+ for x in ["challenge", "session", "challengeParameters"]
94
+ ):
95
+ raise ServerError(
96
+ "Unexpected return from server during MFA challenge. Please check the PAS SDK version and update the PAS SDK if necessary."
97
+ )
98
+ mfa_args["challengeName"] = res["challenge"]
99
+ mfa_args["session"] = res["session"]
100
+ mfa_args["username"] = res["challengeParameters"].get(
101
+ "USER_ID_FOR_SRP", ""
102
+ )
103
+ if not mfa_args["username"]:
104
+ raise ServerError(
105
+ "Unexpected return from server during MFA challenge. Please check the PAS SDK version and update the PAS SDK if necessary."
106
+ )
107
+ print(
108
+ "Multi-factor authentication (MFA) is enabled for your account."
109
+ )
110
+ mfa_code = input(
111
+ "Please enter the code from your authenticator app:"
112
+ )
113
+ mfa_response = s.post(
114
+ f"{self.url}auth/confirmMFA",
115
+ json={
116
+ "username": mfa_args["username"],
117
+ "mfaCode": mfa_code,
118
+ "challengeName": mfa_args["challengeName"],
119
+ "session": mfa_args["session"],
120
+ },
121
+ )
122
+ if mfa_response.status_code != 200:
123
+ raise ServerError(
124
+ "Could not confirm MFA for the PAS instance."
125
+ )
126
+ mfa_response = mfa_response.json()
127
+ if "AuthenticationResult" not in mfa_response:
128
+ raise ServerError(
129
+ "Unexpected return from server during MFA confirmation. Please check the PAS SDK version and update the PAS SDK if necessary."
130
+ )
131
+ mfa_response = mfa_response["AuthenticationResult"]
132
+ if any(
133
+ x not in mfa_response
134
+ for x in [
135
+ "AccessToken",
136
+ "IdToken",
137
+ "ExpiresIn",
138
+ "RefreshToken",
139
+ ]
140
+ ):
141
+ raise ServerError(
142
+ "Unexpected return from server during MFA confirmation. Please check the PAS SDK version and update the PAS SDK if necessary."
143
+ )
144
+ res["id_token"] = mfa_response["IdToken"]
145
+ res["access_token"] = mfa_response["AccessToken"]
146
+ res["expiresIn"] = mfa_response["ExpiresIn"]
147
+ res["refresh_token"] = mfa_response["RefreshToken"]
148
+
149
+ decoded_token = jwt.decode(
150
+ res["id_token"], options={"verify_signature": False}
151
+ )
152
+ self.base_tenant_id = decoded_token["custom:tenantId"]
153
+ self.base_role = decoded_token["custom:role"]
154
+
155
+ if not self.active_tenant_id:
156
+ self.active_tenant_id = self.base_tenant_id
157
+
158
+ if not self.active_role:
159
+ self.active_role = self.base_role
160
+
161
+ self.id_token = res["id_token"]
162
+ self.access_token = res["access_token"]
163
+ self.refresh_token = res.get("refresh_token", None)
164
+ self.token_expiry = int(datetime.now().timestamp()) + res.get(
165
+ "expiresIn", 0
166
+ )
167
+ return res["id_token"], res["access_token"]
168
+
169
+ def _logout(self):
170
+ if not self.has_valid_token():
171
+ return True
172
+ s = requests.Session()
173
+ s.headers.update(
174
+ {"x-seer-source": "sdk", "x-seer-id": f"{self.version}/logout"}
175
+ )
176
+ response = s.post(
177
+ f"{self.url}auth/logout",
178
+ json={
179
+ "username": self.username,
180
+ "refreshtoken": self.refresh_token,
181
+ },
182
+ )
183
+ try:
184
+ response.raise_for_status()
185
+ except Exception as e:
186
+ raise ServerError("Could not logout from the PAS instance")
187
+ self.refresh_token = None
188
+ self.token_expiry = 0
189
+ print(
190
+ f"User {self.username} logged out successfully. Thank you for using the PAS SDK."
191
+ )
192
+ return True
193
+
194
+ def _refresh_token(self):
195
+ """Refreshes the token using the refresh token.
196
+
197
+ Raises:
198
+ ServerError: If the token could not be refreshed.
199
+
200
+ Returns:
201
+ dict: The response from the server containing the new token.
202
+ """
203
+ s = requests.Session()
204
+ s.headers.update(
205
+ {
206
+ "x-seer-source": "sdk",
207
+ "x-seer-id": f"{self.version}/refresh_token",
208
+ }
209
+ )
210
+ response = s.post(
211
+ f"{self.url}auth/refreshtoken",
212
+ json={"refreshtoken": self.refresh_token},
213
+ )
68
214
  if response.status_code == 200:
69
215
  return response.json()
216
+ else:
217
+ raise ServerError("Could not refresh token")
70
218
 
71
219
  def get_token(self):
72
220
  """
73
- Gets the token from the login response.
221
+ Gets the current token. If the token is expired, it refreshes the token using the refresh token.
74
222
 
75
223
  Returns
76
224
  -------
77
225
  str
78
226
  The token from the login response.
79
227
  """
80
-
81
- res = self.login()
228
+ if self.has_valid_token():
229
+ return self.id_token, self.access_token
230
+ else:
231
+ res = self._refresh_token()
82
232
 
83
233
  if "id_token" not in res or "access_token" not in res:
84
234
  raise ValueError(
85
235
  "Check if the credentials are correct or if the backend is running or not."
86
236
  )
87
- decoded_token = jwt.decode(
88
- res["id_token"], options={"verify_signature": False}
237
+ self.token_expiry = int(datetime.now().timestamp()) + res.get(
238
+ "expiresIn", 0
89
239
  )
90
- self.base_tenant_id = decoded_token["custom:tenantId"]
91
- self.base_role = decoded_token["custom:role"]
92
240
 
93
- if not self.active_tenant_id:
94
- self.active_tenant_id = self.base_tenant_id
241
+ self.id_token = res["id_token"]
242
+ self.access_token = res["access_token"]
243
+ return res["id_token"], res["access_token"]
95
244
 
96
- if not self.active_role:
97
- self.active_role = self.base_role
245
+ def has_valid_token(self):
246
+ """
247
+ Check if the id and access tokens are valid.
98
248
 
99
- return res["id_token"], res["access_token"]
249
+ Returns
250
+ -------
251
+ bool
252
+ True if the refresh token is valid, False otherwise.
253
+ """
254
+ return (
255
+ self.access_token is not None
256
+ and self.id_token is not None
257
+ and self.token_expiry > int(datetime.now().timestamp())
258
+ )
@@ -13,7 +13,8 @@ import json
13
13
  import zipfile
14
14
  import tempfile
15
15
 
16
- from ..auth.auth import Auth
16
+ import subprocess
17
+ from importlib.metadata import version, PackageNotFoundError
17
18
 
18
19
  from .groupanalysis import *
19
20
 
@@ -98,7 +99,7 @@ def dict_to_df(data):
98
99
 
99
100
 
100
101
  # Most cases appear to be a .tsv file.
101
- def url_to_df(url, is_tsv=True):
102
+ def download_df(url, is_tsv=True, dtype={}):
102
103
  """
103
104
  Fetches a TSV/CSV file from a URL and returns as a Pandas DataFrame.
104
105
 
@@ -110,6 +111,9 @@ def url_to_df(url, is_tsv=True):
110
111
  is_tsv : bool
111
112
  True if the file is a TSV file, False if it is a CSV file.
112
113
 
114
+ dtype : dict
115
+ Data type conversion when intaking columns. e.g. {'a': str, 'b': np.float64}
116
+
113
117
  Returns
114
118
  -------
115
119
  pandas.core.frame.DataFrame
@@ -122,7 +126,7 @@ def url_to_df(url, is_tsv=True):
122
126
 
123
127
  Examples
124
128
  --------
125
- >>> csv = url_to_df("link_to_csv_file")
129
+ >>> csv = download_df("link_to_csv_file")
126
130
  >>> print(csv)
127
131
  >>> Sample ID Sample name Well location MS file name
128
132
  0 1 SampleName1 1 SDKTest1.raw
@@ -137,9 +141,9 @@ def url_to_df(url, is_tsv=True):
137
141
  return pd.DataFrame()
138
142
  url_content = io.StringIO(requests.get(url).content.decode("utf-8"))
139
143
  if is_tsv:
140
- csv = pd.read_csv(url_content, sep="\t")
144
+ csv = pd.read_csv(url_content, sep="\t", dtype=dtype)
141
145
  else:
142
- csv = pd.read_csv(url_content)
146
+ csv = pd.read_csv(url_content, dtype=dtype)
143
147
  return csv
144
148
 
145
149
 
@@ -720,3 +724,40 @@ def rename_d_zip_file(source, destination):
720
724
  zip_ref.write(file_path, arcname)
721
725
 
722
726
  print(f"Renamed {source} to {destination}")
727
+
728
+
729
+ def get_version():
730
+ """
731
+ Returns the version of the seer-pas-sdk package.
732
+ """
733
+ v = ""
734
+ try:
735
+ v = version("seer-pas-sdk")
736
+ v = "v" + v
737
+ if "dev" in v:
738
+ v = v[: v.index("dev") - 1].strip()
739
+ v += "-dev"
740
+ except Exception as e:
741
+ pass
742
+
743
+ if not v:
744
+ try:
745
+ v = subprocess.check_output(
746
+ ["git", "describe", "--tags"], text=True
747
+ ).strip()
748
+ if "-" in v:
749
+ v = v.split("-")[0]
750
+ v += "-dev"
751
+ except subprocess.CalledProcessError:
752
+ v = "unknown"
753
+ return f"{v}"
754
+
755
+
756
+ def title_case_to_snake_case(s: str):
757
+ """
758
+ Converts a title case string to snake case.
759
+
760
+ """
761
+ s = s.replace(" ", "_").replace(".", "_").casefold()
762
+
763
+ return s