pcss-qapi 0.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.
pcss_qapi/__init__.py ADDED
@@ -0,0 +1,5 @@
1
+ """Python client for pccs quantum api"""
2
+
3
+ from pcss_qapi.auth import AuthorizationService
4
+
5
+ __all__ = ["AuthorizationService"]
@@ -0,0 +1,4 @@
1
+ """Authorization workflows for pcss_qapi"""
2
+ from .auth_service import AuthorizationService
3
+
4
+ __all__ = ['AuthorizationService']
@@ -0,0 +1,132 @@
1
+ """Authorization service for pcss_qapi"""
2
+ import json
3
+ import os
4
+
5
+ import hashlib
6
+
7
+ from pcss_qapi.auth.oauth_flow import OAuthManager
8
+ from pcss_qapi.utils.fs_manager import FSManager, API_KEY_FILE_NAME
9
+
10
+ from pcss_qapi.base.connection_base import ApiConnection
11
+ from pcss_qapi.auth.connections import PcssQapiConnection
12
+
13
+ OAUTH_MANAGERS: dict[str, OAuthManager] = {}
14
+
15
+
16
+ def get_manager_for_connection(connection: ApiConnection) -> OAuthManager:
17
+ """Get oauth manager for given connection type, create new if not existing"""
18
+ connection_manager = OAUTH_MANAGERS.get(str(hash(connection)), None)
19
+ if connection_manager is None:
20
+ connection_manager = OAuthManager(
21
+ connection.oauth_client_id,
22
+ connection.oauth_issuer,
23
+ connection.oauth_min_ttl_seconds
24
+ )
25
+ OAUTH_MANAGERS[str(hash(connection))] = connection_manager
26
+ return connection_manager
27
+
28
+
29
+ def get_hash(issuer: str, client_id: str) -> str:
30
+ """get hash for connection"""
31
+ return hashlib.sha256(f'{issuer}{client_id}'.encode('utf-8')).hexdigest()
32
+
33
+
34
+ class AuthorizationService:
35
+ """
36
+ Authentication and login handler for pcss_qapi service
37
+ """
38
+
39
+ @staticmethod
40
+ def _get_functional_tokens(oauth_manager: OAuthManager) -> tuple[str | None, str | None]:
41
+ """Get access and refresh tokens, refresh if necessary."""
42
+ storage_path = FSManager.get_storage_path()
43
+ filepath = os.path.join(storage_path, API_KEY_FILE_NAME)
44
+
45
+ if not os.path.exists(filepath):
46
+ return None, None
47
+
48
+ key_hash = get_hash(oauth_manager.issuer, oauth_manager.client_id)
49
+
50
+ access_token, refresh_token = None, None
51
+ with open(filepath, 'r', encoding='UTF-8') as f:
52
+ key_infos = json.loads(f.read())
53
+ key_info = key_infos.get(key_hash, None)
54
+ if key_info is None:
55
+ return None, None
56
+
57
+ access_token, refresh_token = key_info.get('access_token', None), key_info.get('refresh_token', None)
58
+ if access_token is None or refresh_token is None:
59
+ return None, None
60
+
61
+ if not oauth_manager.is_token_valid(access_token):
62
+ access_token, refresh_token = oauth_manager.get_refreshed_tokens(refresh_token)
63
+
64
+ if access_token is not None and refresh_token is not None:
65
+ FSManager.update_credentials(
66
+ key_hash,
67
+ {
68
+ 'refresh_token': refresh_token,
69
+ 'access_token': access_token
70
+ }) # Save refreshed tokens
71
+
72
+ return access_token, refresh_token
73
+
74
+ @staticmethod
75
+ def login(
76
+ connection: ApiConnection = PcssQapiConnection,
77
+ ):
78
+ """
79
+ Cache api key string for later use.
80
+
81
+ Args:
82
+ """
83
+ storage_location = FSManager.get_storage_path()
84
+
85
+ storage_path = FSManager.get_fixed_path(storage_location)
86
+ os.makedirs(storage_path, exist_ok=True)
87
+
88
+ connection_manager = get_manager_for_connection(connection)
89
+ key_hash = get_hash(connection_manager.issuer, connection_manager.client_id)
90
+
91
+ access_token, refresh_token = AuthorizationService._get_functional_tokens(connection_manager)
92
+ if access_token is not None and refresh_token is not None:
93
+ print("ℹ️ Using cached tokens. If you want to change the account use AuthorizationService.logout() first.")
94
+ return
95
+
96
+ access_token, refresh_token = connection_manager.get_device_flow_tokens()
97
+ if access_token is None or refresh_token is None:
98
+ raise ValueError("Authorization error, did not receive tokens.")
99
+
100
+ FSManager.update_credentials(
101
+ key_hash,
102
+ {
103
+ 'refresh_token': refresh_token,
104
+ 'access_token': access_token
105
+ })
106
+
107
+ @staticmethod
108
+ def logout(
109
+ connection: ApiConnection | None = None
110
+ ):
111
+ """Remove credentials for active account"""
112
+
113
+ filepath = os.path.join(FSManager.get_storage_path(), API_KEY_FILE_NAME)
114
+ if connection is None:
115
+ if os.path.exists(filepath):
116
+ os.remove(filepath)
117
+ return
118
+
119
+ FSManager.remove_credentials(
120
+ get_hash(connection.oauth_issuer, connection.oauth_client_id)
121
+ )
122
+
123
+ @staticmethod
124
+ def get_api_key(connection: ApiConnection) -> str:
125
+ """Get api key string if available. Automatically logs out if unable to refresh key."""
126
+
127
+ connection_manager = get_manager_for_connection(connection)
128
+ access_token, _ = AuthorizationService._get_functional_tokens(connection_manager)
129
+ if access_token is None:
130
+ AuthorizationService.logout()
131
+ raise ValueError("Use AuthorizationService.login()")
132
+ return access_token
@@ -0,0 +1,15 @@
1
+ """Connection definitions"""
2
+
3
+ from pcss_qapi.base.connection_base import ApiConnection
4
+
5
+ PcssQapiConnection = ApiConnection(
6
+ oauth_client_id='jupyter.quantum.psnc.pl-notebooks',
7
+ oauth_issuer='https://sso.classroom.pionier.net.pl/auth/realms/Classroom',
8
+ oauth_min_ttl_seconds=60,
9
+ api_health_url='https://api.quantum.psnc.pl/api/health/test-connection',
10
+ task_endpoints_url='https://api.quantum.psnc.pl/api/client/tasks',
11
+ machine_endpoints_url='https://api.quantum.psnc.pl/api/client/machines',
12
+ )
13
+
14
+
15
+ __all__ = ['PcssQapiConnection']
@@ -0,0 +1,203 @@
1
+ """OAuth authorization management"""
2
+ import time
3
+ import base64
4
+ import json
5
+ from typing import Any
6
+ import requests
7
+
8
+
9
+ def _print_nothing(width):
10
+ print("".join(' ' for _ in range(width)), end='\r')
11
+
12
+
13
+ def _print_formatted_time(time_seconds):
14
+ spinner = "⣾⣽⣻⢿⡿⣟⣯⣷"
15
+
16
+ minutes_left = time_seconds // 60
17
+ seconds_left = time_seconds % 60
18
+
19
+ minutes_string = f'{minutes_left} minutes'
20
+ seconds_string = f'{seconds_left} seconds'
21
+
22
+ s = f"""{spinner[time_seconds % len(spinner)]} Waiting... {" ".join([minutes_string if minutes_left else "",
23
+ seconds_string])} remaining to complete authorization."""
24
+ print(s, end='\r')
25
+ return len(s)
26
+
27
+
28
+ def _show_error(error_type: str | None, last_printed_line_len: int):
29
+ if error_type == "authorization_pending":
30
+ pass
31
+ else:
32
+ error_message = {
33
+ 'slow_down': '⏳ Server requested slower polling. Increasing interval.',
34
+ 'access_denied': '❌ Access was denied by user.',
35
+ None: '❌ Failed to parse polling error response.'
36
+ }.get(error_type, '❌ Failed to parse polling error response.')
37
+
38
+ _print_nothing(last_printed_line_len)
39
+ print(error_message)
40
+
41
+
42
+ def _decode_jwt_payload(token):
43
+ try:
44
+ parts = token.split(".")
45
+ if len(parts) != 3:
46
+ return None
47
+ padded = parts[1] + "=" * (-len(parts[1]) % 4)
48
+ decoded_bytes = base64.urlsafe_b64decode(padded)
49
+ return json.loads(decoded_bytes)
50
+ except Exception: # pylint:disable = broad-exception-caught
51
+ return None
52
+
53
+
54
+ class OAuthManager:
55
+ """Token handler"""
56
+
57
+ def __init__(self, client_id, issuer, min_ttl) -> None:
58
+ self.client_id = client_id
59
+ self.issuer = issuer
60
+ self.min_ttl = min_ttl
61
+
62
+ self.token_endpoint: str | None = None
63
+ self.device_endpoint: str | None = None
64
+
65
+ self._try_get_urls()
66
+
67
+ def _try_get_urls(self) -> bool:
68
+ success = False
69
+ try:
70
+ discovery_url = f"{self.issuer}/.well-known/openid-configuration"
71
+ discovery = requests.get(discovery_url, timeout=5).json()
72
+ self.token_endpoint = discovery["token_endpoint"]
73
+ self.device_endpoint = discovery["device_authorization_endpoint"]
74
+ success = True
75
+ except requests.exceptions.ConnectionError:
76
+ pass
77
+ return success
78
+
79
+ def is_token_valid(self, token):
80
+ """Check if token will be valid for more than self.min_ttl seconds."""
81
+ payload = _decode_jwt_payload(token)
82
+ if not payload or "exp" not in payload:
83
+ return False
84
+ exp = payload["exp"]
85
+ return (exp - int(time.time())) >= self.min_ttl
86
+
87
+ def get_refreshed_tokens(self, refresh_token) -> tuple[str | None, str | None]:
88
+ """Get new access and refresh tokens fr"""
89
+ if (self.token_endpoint is None or self.device_endpoint is None) and not self._try_get_urls():
90
+ raise RuntimeError("Authentication server inaccessible")
91
+
92
+ resp = requests.post(
93
+ self.token_endpoint,
94
+ data={
95
+ "grant_type": "refresh_token",
96
+ "client_id": self.client_id,
97
+ "refresh_token": refresh_token
98
+ },
99
+ timeout=20)
100
+ if resp.ok:
101
+ data = resp.json()
102
+ return data.get("access_token"), data.get("refresh_token")
103
+
104
+ # print(f"⚠️ Refresh grant failed (HTTP {resp.status_code}): {resp.text}")
105
+ return None, None
106
+
107
+ def _get_device_flow_data(self) -> dict[str, Any]:
108
+ response = requests.post(
109
+ self.device_endpoint,
110
+ data={
111
+ "client_id": self.client_id
112
+ },
113
+ timeout=20)
114
+ if not response.ok:
115
+ try:
116
+ error_data = response.json()
117
+ error = error_data.get("error", "unknown_error")
118
+ description = error_data.get("error_description", "No description.")
119
+ except Exception: # pylint:disable = broad-exception-caught
120
+ error = "unknown_error"
121
+ description = response.text
122
+ raise RuntimeError(f"Device Authorization Error: {error} — {description}") # pylint:disable = broad-exception-raised
123
+
124
+ if 'application/json' not in response.headers.get('content-type', ''):
125
+ raise ValueError("Invalid device flow response.")
126
+
127
+ data = response.json()
128
+ required_fields = ["verification_uri_complete", "device_code", "expires_in"]
129
+ if not all(field in data for field in required_fields):
130
+ raise ValueError("Missing expected fields in device flow response.") # pylint:disable = broad-exception-raised
131
+
132
+ return data
133
+
134
+ def _poll_auth_server(self, device_code) -> tuple[str | None, str | None, str | None]:
135
+ poll = requests.post(
136
+ self.token_endpoint,
137
+ data={
138
+ "grant_type": "urn:ietf:params:oauth:grant-type:device_code",
139
+ "device_code": device_code,
140
+ "client_id": self.client_id
141
+ },
142
+ timeout=20)
143
+ if poll.status_code == 200:
144
+ t = poll.json()
145
+ return t.get("access_token"), t.get("refresh_token"), None
146
+ if poll.status_code == 400:
147
+ err = None
148
+ try:
149
+ err = poll.json().get("error")
150
+ except requests.JSONDecodeError:
151
+ pass
152
+ return None, None, err
153
+ return None, None, None
154
+
155
+ def get_device_flow_tokens(self) -> tuple[str | None, str | None]: # pylint:disable = too-many-statements,too-many-locals
156
+ """Get new access and refresh tokens from device flow."""
157
+ if (self.token_endpoint is None or self.device_endpoint is None) and not self._try_get_urls():
158
+ raise RuntimeError("Authentication server inaccessible")
159
+
160
+ data = self._get_device_flow_data()
161
+
162
+ print("\n🔐 Authorize Access")
163
+ print("----------------------------------------")
164
+ print("You are about to be redirected to an authorization server.")
165
+ print("There, you will be asked to grant access permissions.")
166
+ print("This allows the system to act on your behalf using delegated access.")
167
+ print("Please confirm only if you trust this application.")
168
+ print(f"➡️ Click to authorize: {data['verification_uri_complete']}")
169
+ print()
170
+
171
+ device_code = data["device_code"]
172
+ interval = data.get("interval", 5)
173
+ expires_in = data["expires_in"]
174
+
175
+ sleep_time = 1
176
+ interval_count = 0
177
+
178
+ last_printed_line_len = 0
179
+ while expires_in > 0:
180
+ if interval_count < 0:
181
+ interval_count = interval
182
+
183
+ access_token, refresh_token, error_type = self._poll_auth_server(device_code)
184
+
185
+ if access_token is not None and refresh_token is not None:
186
+ _print_nothing(last_printed_line_len)
187
+ print("✅ Access granted.")
188
+ return access_token, refresh_token
189
+
190
+ _show_error(error_type, last_printed_line_len)
191
+
192
+ if error_type == 'slow_down':
193
+ interval += 1
194
+ elif error_type != 'authorization_pending':
195
+ return None, None
196
+
197
+ expires_in -= sleep_time
198
+ interval_count -= sleep_time
199
+ last_printed_line_len = _print_formatted_time(expires_in)
200
+ time.sleep(sleep_time)
201
+
202
+ print("\n❌ Timeout: Authorization not completed in time.")
203
+ return None, None
File without changes
@@ -0,0 +1,20 @@
1
+ """Connection base"""
2
+ from dataclasses import dataclass
3
+
4
+
5
+ @dataclass(frozen=True)
6
+ class ApiConnection:
7
+ """Base class for defining a new api connection"""
8
+
9
+ oauth_client_id: str
10
+ oauth_issuer: str
11
+ oauth_min_ttl_seconds: int
12
+ api_health_url: str
13
+ task_endpoints_url: str
14
+ machine_endpoints_url: str
15
+
16
+ def __str__(self) -> str:
17
+ return f"<{self.task_endpoints_url.replace('https://', '').replace('http://', '').split('/', maxsplit=1)[0]}>"
18
+
19
+ def __repr__(self) -> str:
20
+ return str(self)
@@ -0,0 +1,52 @@
1
+ """Job ABCs"""
2
+ import os
3
+ import json
4
+ from typing import Any
5
+ from abc import ABC, abstractmethod
6
+ from pcss_qapi.utils import FSManager
7
+
8
+
9
+ class BaseRemoteJob(ABC):
10
+ """Blueprint for an api remote job (non qiskit)"""
11
+
12
+ def __init__(self, job_type: str) -> None:
13
+ super().__init__()
14
+ self.uid = None
15
+ self.type = job_type # Provider type
16
+
17
+ @abstractmethod
18
+ def _is_remote_available(self) -> bool:
19
+ """Check if remote api is up"""
20
+ @abstractmethod
21
+ def _submit(self):
22
+ """Submit job to remote"""
23
+
24
+ @abstractmethod
25
+ def _get_job_metadata(self) -> dict:
26
+ """Additional metadata to save"""
27
+
28
+ def _save_job(self):
29
+ save_path = FSManager.get_task_directory(self.type)
30
+ os.makedirs(save_path, exist_ok=True)
31
+ with open(os.path.join(save_path, f'{self.uid}.json'), 'w+', encoding='UTF-8') as f:
32
+ f.write(json.dumps(self._get_job_metadata()))
33
+
34
+ def submit(self) -> None:
35
+ """Submit job to remote"""
36
+ if not self._is_remote_available():
37
+ raise ValueError('Remote server is unreachable')
38
+ self._submit()
39
+ self._save_job()
40
+
41
+ @property
42
+ @abstractmethod
43
+ def status(self) -> Any:
44
+ """Task status"""
45
+
46
+ @abstractmethod
47
+ def results(self) -> Any:
48
+ """Get task results"""
49
+
50
+ @abstractmethod
51
+ def cancel(self) -> bool:
52
+ """Attempt to cancel task if it is queued, return True if successful"""
@@ -0,0 +1,38 @@
1
+ """Provider ABCs"""
2
+ from abc import ABC, abstractmethod
3
+ from typing import Any
4
+
5
+ from pcss_qapi.base.connection_base import ApiConnection
6
+ from pcss_qapi.auth.connections import PcssQapiConnection
7
+
8
+
9
+ class BaseProvider(ABC):
10
+ """Backend provider ABC"""
11
+
12
+ def __init__(
13
+ self,
14
+ connection: ApiConnection = PcssQapiConnection
15
+ ) -> None:
16
+ self.connection = connection
17
+
18
+ @abstractmethod
19
+ def get_backend(self, backend_name) -> Any:
20
+ """Get backend with the name of backend_name"""
21
+
22
+ @abstractmethod
23
+ def available_backends(self, simulators: bool = False) -> list[str]:
24
+ """
25
+ Return names of available backends for the authed user.
26
+
27
+ Returns:
28
+ list[str]: Backend names.
29
+ """
30
+
31
+ @abstractmethod
32
+ def least_busy(self) -> Any:
33
+ """
34
+ Return least busy backend from the pool of **real** backends.
35
+
36
+ Returns:
37
+ object: Least busy backend.
38
+ """
@@ -0,0 +1,15 @@
1
+ """ORCA Computing providers, backends, etc."""
2
+ import warnings
3
+
4
+ from pcss_qapi.utils.exceptions import MissingOptionalDependencyError
5
+ from .orca_task import OrcaTask
6
+
7
+ __all__ = ['OrcaTask']
8
+
9
+ # We want OrcaTask to be available even without ptseries
10
+ try:
11
+ from .backend import OrcaBackend
12
+ from .provider import OrcaProvider
13
+ __all__ += ['OrcaProvider', 'OrcaBackend']
14
+ except MissingOptionalDependencyError as e:
15
+ warnings.warn(e.msg)