adss 1.0__py3-none-any.whl → 1.2__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.
@@ -0,0 +1,311 @@
1
+ """
2
+ User management functionality for the Astronomy TAP Client.
3
+ """
4
+ import requests
5
+ from typing import Dict, List, Optional, Any
6
+
7
+ from adss.exceptions import (
8
+ AuthenticationError, ResourceNotFoundError, PermissionDeniedError,
9
+ ValidationError
10
+ )
11
+ from adss.utils import handle_response_errors
12
+ from adss.models.user import User, Role
13
+
14
+
15
+ class UsersEndpoint:
16
+ """
17
+ Handles user management operations.
18
+ """
19
+
20
+ def __init__(self, base_url: str, auth_manager):
21
+ """
22
+ Initialize the Users endpoint.
23
+
24
+ Args:
25
+ base_url: The base URL of the API server
26
+ auth_manager: Authentication manager providing auth headers
27
+ """
28
+ self.base_url = base_url.rstrip('/')
29
+ self.auth_manager = auth_manager
30
+
31
+ def register(self, username: str, email: str, password: str, full_name: Optional[str] = None, **kwargs) -> User:
32
+ """
33
+ Register a new user account.
34
+
35
+ Args:
36
+ username: Desired username
37
+ email: User's email address
38
+ password: User's password (will be validated for strength)
39
+ full_name: Optional full name
40
+ **kwargs: Additional keyword arguments to pass to the request (e.g., verify=False)
41
+
42
+ Returns:
43
+ The newly created User object
44
+
45
+ Raises:
46
+ ValidationError: If input validation fails (e.g., password too weak)
47
+ """
48
+ data = {
49
+ "username": username,
50
+ "email": email,
51
+ "password": password
52
+ }
53
+
54
+ if full_name:
55
+ data["full_name"] = full_name
56
+
57
+ try:
58
+ response = self.auth_manager.request(
59
+ method="POST",
60
+ url="/adss/v1/users",
61
+ json=data,
62
+ **kwargs
63
+ )
64
+ handle_response_errors(response)
65
+
66
+ user_data = response.json()
67
+ return User.from_dict(user_data)
68
+
69
+ except Exception as e:
70
+ if hasattr(e, 'response') and e.response.status_code == 400:
71
+ try:
72
+ error_data = e.response.json()
73
+ raise ValidationError(
74
+ f"User registration failed: {error_data.get('detail', str(e))}",
75
+ error_data.get('errors')
76
+ )
77
+ except (ValueError, AttributeError):
78
+ pass
79
+ raise
80
+
81
+ def get_me(self, **kwargs) -> User:
82
+ """
83
+ Get information about the currently authenticated user.
84
+
85
+ Args:
86
+ **kwargs: Additional keyword arguments to pass to the request (e.g., verify=False)
87
+
88
+ Returns:
89
+ The current User object
90
+
91
+ Raises:
92
+ AuthenticationError: If not authenticated
93
+ """
94
+ try:
95
+ response = self.auth_manager.request(
96
+ method="GET",
97
+ url="/adss/v1/users/me",
98
+ auth_required=True,
99
+ **kwargs
100
+ )
101
+ handle_response_errors(response)
102
+
103
+ user_data = response.json()
104
+ return User.from_dict(user_data)
105
+
106
+ except Exception as e:
107
+ if hasattr(e, 'response') and e.response.status_code == 401:
108
+ raise AuthenticationError("Authentication required")
109
+ raise
110
+
111
+ def update_profile(self,
112
+ email: Optional[str] = None,
113
+ full_name: Optional[str] = None,
114
+ **kwargs) -> User:
115
+ """
116
+ Update the current user's profile information.
117
+
118
+ Args:
119
+ email: New email address (optional)
120
+ full_name: New full name (optional)
121
+ **kwargs: Additional keyword arguments to pass to the request (e.g., verify=False)
122
+
123
+ Returns:
124
+ The updated User object
125
+
126
+ Raises:
127
+ AuthenticationError: If not authenticated
128
+ ValidationError: If input validation fails
129
+ """
130
+ data = {}
131
+ if email is not None:
132
+ data["email"] = email
133
+ if full_name is not None:
134
+ data["full_name"] = full_name
135
+
136
+ if not data:
137
+ # Nothing to update
138
+ return self.get_me(**kwargs)
139
+
140
+ try:
141
+ response = self.auth_manager.request(
142
+ method="PATCH",
143
+ url="/adss/v1/users/me",
144
+ json=data,
145
+ auth_required=True,
146
+ **kwargs
147
+ )
148
+ handle_response_errors(response)
149
+
150
+ user_data = response.json()
151
+ return User.from_dict(user_data)
152
+
153
+ except Exception as e:
154
+ if hasattr(e, 'response') and e.response.status_code == 400:
155
+ try:
156
+ error_data = e.response.json()
157
+ raise ValidationError(
158
+ f"Profile update failed: {error_data.get('detail', str(e))}",
159
+ error_data.get('errors')
160
+ )
161
+ except (ValueError, AttributeError):
162
+ pass
163
+ elif hasattr(e, 'response') and e.response.status_code == 401:
164
+ raise AuthenticationError("Authentication required")
165
+ raise
166
+
167
+ def get_users(self, skip: int = 0, limit: int = 100, **kwargs) -> List[User]:
168
+ """
169
+ Get a list of users (staff only).
170
+
171
+ Args:
172
+ skip: Number of users to skip (for pagination)
173
+ limit: Maximum number of users to return
174
+ **kwargs: Additional keyword arguments to pass to the request (e.g., verify=False)
175
+
176
+ Returns:
177
+ List of User objects
178
+
179
+ Raises:
180
+ AuthenticationError: If not authenticated
181
+ PermissionDeniedError: If not a staff user
182
+ """
183
+ params = {"skip": skip, "limit": limit}
184
+
185
+ try:
186
+ response = self.auth_manager.request(
187
+ method="GET",
188
+ url="/adss/v1/users",
189
+ params=params,
190
+ auth_required=True,
191
+ **kwargs
192
+ )
193
+ handle_response_errors(response)
194
+
195
+ users_data = response.json()
196
+ return [User.from_dict(user_data) for user_data in users_data]
197
+
198
+ except Exception as e:
199
+ if hasattr(e, 'response'):
200
+ if e.response.status_code == 401:
201
+ raise AuthenticationError("Authentication required")
202
+ elif e.response.status_code == 403:
203
+ raise PermissionDeniedError("Staff access required")
204
+ raise
205
+
206
+ def get_user(self, user_id: str, **kwargs) -> User:
207
+ """
208
+ Get information about a specific user (staff only).
209
+
210
+ Args:
211
+ user_id: ID of the user to get
212
+ **kwargs: Additional keyword arguments to pass to the request (e.g., verify=False)
213
+
214
+ Returns:
215
+ User object
216
+
217
+ Raises:
218
+ AuthenticationError: If not authenticated
219
+ PermissionDeniedError: If not a staff user
220
+ ResourceNotFoundError: If the user is not found
221
+ """
222
+ try:
223
+ response = self.auth_manager.request(
224
+ method="GET",
225
+ url=f"/adss/v1/users/{user_id}",
226
+ auth_required=True,
227
+ **kwargs
228
+ )
229
+ handle_response_errors(response)
230
+
231
+ user_data = response.json()
232
+ return User.from_dict(user_data)
233
+
234
+ except Exception as e:
235
+ if hasattr(e, 'response'):
236
+ if e.response.status_code == 401:
237
+ raise AuthenticationError("Authentication required")
238
+ elif e.response.status_code == 403:
239
+ raise PermissionDeniedError("Staff access required")
240
+ elif e.response.status_code == 404:
241
+ raise ResourceNotFoundError(f"User not found: {user_id}")
242
+ raise
243
+
244
+ def update_user(self,
245
+ user_id: str,
246
+ email: Optional[str] = None,
247
+ full_name: Optional[str] = None,
248
+ is_active: Optional[bool] = None,
249
+ **kwargs) -> User:
250
+ """
251
+ Update a user's information (staff only).
252
+
253
+ Args:
254
+ user_id: ID of the user to update
255
+ email: New email address (optional)
256
+ full_name: New full name (optional)
257
+ is_active: New active status (optional)
258
+ **kwargs: Additional keyword arguments to pass to the request (e.g., verify=False)
259
+
260
+ Returns:
261
+ The updated User object
262
+
263
+ Raises:
264
+ AuthenticationError: If not authenticated
265
+ PermissionDeniedError: If not a staff user
266
+ ResourceNotFoundError: If the user is not found
267
+ ValidationError: If input validation fails
268
+ """
269
+ data = {}
270
+ if email is not None:
271
+ data["email"] = email
272
+ if full_name is not None:
273
+ data["full_name"] = full_name
274
+ if is_active is not None:
275
+ data["is_active"] = is_active
276
+
277
+ if not data:
278
+ # Nothing to update
279
+ return self.get_user(user_id, **kwargs)
280
+
281
+ try:
282
+ response = self.auth_manager.request(
283
+ method="PATCH",
284
+ url=f"/adss/v1/users/{user_id}",
285
+ json=data,
286
+ auth_required=True,
287
+ **kwargs
288
+ )
289
+ handle_response_errors(response)
290
+
291
+ user_data = response.json()
292
+ return User.from_dict(user_data)
293
+
294
+ except Exception as e:
295
+ if hasattr(e, 'response'):
296
+ if e.response.status_code == 401:
297
+ raise AuthenticationError("Authentication required")
298
+ elif e.response.status_code == 403:
299
+ raise PermissionDeniedError("Staff access required")
300
+ elif e.response.status_code == 404:
301
+ raise ResourceNotFoundError(f"User not found: {user_id}")
302
+ elif e.response.status_code == 400:
303
+ try:
304
+ error_data = e.response.json()
305
+ raise ValidationError(
306
+ f"User update failed: {error_data.get('detail', str(e))}",
307
+ error_data.get('errors')
308
+ )
309
+ except (ValueError, AttributeError):
310
+ pass
311
+ raise
adss/exceptions.py ADDED
@@ -0,0 +1,57 @@
1
+ """
2
+ Custom exceptions for the Astronomy TAP Client.
3
+ """
4
+
5
+ class ADSSClientError(Exception):
6
+ """Base exception for all TAP client errors."""
7
+
8
+ def __init__(self, message, response=None):
9
+ self.message = message
10
+ self.response = response
11
+ super().__init__(self.message)
12
+
13
+
14
+ class AuthenticationError(ADSSClientError):
15
+ """Exception raised for authentication failures."""
16
+ pass
17
+
18
+
19
+ class PermissionDeniedError(ADSSClientError):
20
+ """Exception raised when the user doesn't have sufficient permissions."""
21
+ pass
22
+
23
+
24
+ class ResourceNotFoundError(ADSSClientError):
25
+ """Exception raised when a requested resource is not found."""
26
+ pass
27
+
28
+
29
+ class QueryExecutionError(ADSSClientError):
30
+ """Exception raised when a query fails to execute."""
31
+
32
+ def __init__(self, message, query=None, response=None):
33
+ self.query = query
34
+ super().__init__(message, response)
35
+
36
+
37
+ class ValidationError(ADSSClientError):
38
+ """Exception raised when input validation fails."""
39
+
40
+ def __init__(self, message, errors=None, response=None):
41
+ self.errors = errors or {}
42
+ super().__init__(message, response)
43
+
44
+
45
+ class ConnectionError(ADSSClientError):
46
+ """Exception raised when connection to the API server fails."""
47
+ pass
48
+
49
+
50
+ class TimeoutError(ADSSClientError):
51
+ """Exception raised when a request times out."""
52
+ pass
53
+
54
+
55
+ class ServerError(ADSSClientError):
56
+ """Exception raised when the server returns a 5xx error."""
57
+ pass
@@ -0,0 +1,13 @@
1
+ """
2
+ Data models for the Astronomy TAP Client.
3
+ """
4
+
5
+ from .user import User, Role, SchemaPermission, TablePermission, RolePermissions
6
+ from .query import Query, QueryResult
7
+ from .metadata import Column, Table, Schema, DatabaseMetadata
8
+
9
+ __all__ = [
10
+ 'User', 'Role', 'SchemaPermission', 'TablePermission', 'RolePermissions',
11
+ 'Query', 'QueryResult',
12
+ 'Column', 'Table', 'Schema', 'DatabaseMetadata'
13
+ ]
@@ -0,0 +1,138 @@
1
+ """
2
+ Database metadata models for the Astronomy TAP Client.
3
+ """
4
+ from dataclasses import dataclass, field
5
+ from typing import List, Dict, Any, Optional
6
+
7
+
8
+ @dataclass
9
+ class Column:
10
+ """
11
+ Represents a database column and its metadata.
12
+ """
13
+ name: str
14
+ data_type: str
15
+ is_nullable: bool
16
+
17
+ @classmethod
18
+ def from_dict(cls, data: Dict[str, Any]) -> 'Column':
19
+ """Create a Column object from a dictionary."""
20
+ return cls(
21
+ name=data.get('name'),
22
+ data_type=data.get('data_type'),
23
+ is_nullable=data.get('is_nullable', False)
24
+ )
25
+
26
+
27
+ @dataclass
28
+ class Table:
29
+ """
30
+ Represents a database table and its columns.
31
+ """
32
+ name: str
33
+ columns: List[Column] = field(default_factory=list)
34
+
35
+ @classmethod
36
+ def from_dict(cls, data: Dict[str, Any]) -> 'Table':
37
+ """Create a Table object from a dictionary."""
38
+ columns = [
39
+ Column.from_dict(col_data)
40
+ for col_data in data.get('columns', [])
41
+ ]
42
+
43
+ return cls(
44
+ name=data.get('name'),
45
+ columns=columns
46
+ )
47
+
48
+ def get_column(self, name: str) -> Optional[Column]:
49
+ """Get a column by name."""
50
+ for column in self.columns:
51
+ if column.name == name:
52
+ return column
53
+ return None
54
+
55
+ def has_column(self, name: str) -> bool:
56
+ """Check if the table has a column with the given name."""
57
+ return any(col.name == name for col in self.columns)
58
+
59
+ def column_names(self) -> List[str]:
60
+ """Get a list of all column names."""
61
+ return [col.name for col in self.columns]
62
+
63
+
64
+ @dataclass
65
+ class Schema:
66
+ """
67
+ Represents a database schema and its tables.
68
+ """
69
+ name: str
70
+ tables: List[Table] = field(default_factory=list)
71
+
72
+ @classmethod
73
+ def from_dict(cls, data: Dict[str, Any]) -> 'Schema':
74
+ """Create a Schema object from a dictionary."""
75
+ tables = [
76
+ Table.from_dict(table_data)
77
+ for table_data in data.get('tables', [])
78
+ ]
79
+
80
+ return cls(
81
+ name=data.get('name'),
82
+ tables=tables
83
+ )
84
+
85
+ def get_table(self, name: str) -> Optional[Table]:
86
+ """Get a table by name."""
87
+ for table in self.tables:
88
+ if table.name == name:
89
+ return table
90
+ return None
91
+
92
+ def has_table(self, name: str) -> bool:
93
+ """Check if the schema has a table with the given name."""
94
+ return any(table.name == name for table in self.tables)
95
+
96
+ def table_names(self) -> List[str]:
97
+ """Get a list of all table names in the schema."""
98
+ return [table.name for table in self.tables]
99
+
100
+
101
+ @dataclass
102
+ class DatabaseMetadata:
103
+ """
104
+ Represents database metadata, including schemas and their tables.
105
+ """
106
+ schemas: List[Schema] = field(default_factory=list)
107
+
108
+ @classmethod
109
+ def from_dict(cls, data: Dict[str, Any]) -> 'DatabaseMetadata':
110
+ """Create a DatabaseMetadata object from a dictionary."""
111
+ schemas = [
112
+ Schema.from_dict(schema_data)
113
+ for schema_data in data.get('schemas', [])
114
+ ]
115
+
116
+ return cls(schemas=schemas)
117
+
118
+ def get_schema(self, name: str) -> Optional[Schema]:
119
+ """Get a schema by name."""
120
+ for schema in self.schemas:
121
+ if schema.name == name:
122
+ return schema
123
+ return None
124
+
125
+ def has_schema(self, name: str) -> bool:
126
+ """Check if the database has a schema with the given name."""
127
+ return any(schema.name == name for schema in self.schemas)
128
+
129
+ def schema_names(self) -> List[str]:
130
+ """Get a list of all schema names."""
131
+ return [schema.name for schema in self.schemas]
132
+
133
+ def get_table(self, schema_name: str, table_name: str) -> Optional[Table]:
134
+ """Get a table by schema and table name."""
135
+ schema = self.get_schema(schema_name)
136
+ if schema:
137
+ return schema.get_table(table_name)
138
+ return None
adss/models/query.py ADDED
@@ -0,0 +1,134 @@
1
+ """
2
+ Query-related data models for the Astronomy TAP Client.
3
+ """
4
+ from dataclasses import dataclass
5
+ from typing import Dict, Optional, Any, List
6
+ from datetime import datetime
7
+ import pandas as pd
8
+
9
+ from adss.utils import parse_datetime
10
+
11
+
12
+ @dataclass
13
+ class Query:
14
+ """
15
+ Represents a database query and its metadata.
16
+ """
17
+ id: str
18
+ query_text: str
19
+ status: str # 'PENDING', 'QUEUED', 'RUNNING', 'COMPLETED', 'ERROR'
20
+ created_at: datetime
21
+ mode: str = 'adql' # 'adql' or 'sql'
22
+ user_id: Optional[str] = None
23
+ completed_at: Optional[datetime] = None
24
+ result_url: Optional[str] = None
25
+ error: Optional[str] = None
26
+ execution_time_ms: Optional[int] = None
27
+ row_count: Optional[int] = None
28
+ position_in_queue: Optional[int] = None
29
+ expires_at: Optional[datetime] = None
30
+ query_metadata: Optional[Dict[str, Any]] = None
31
+
32
+ @classmethod
33
+ def from_dict(cls, data: Dict[str, Any]) -> 'Query':
34
+ """Create a Query object from a dictionary."""
35
+ query_id = data.get('id')
36
+ query_text = data.get('query_text')
37
+ status = data.get('status')
38
+ mode = data.get('mode', 'adql')
39
+ user_id = data.get('user_id')
40
+
41
+ created_at = parse_datetime(data.get('created_at'))
42
+ completed_at = parse_datetime(data.get('completed_at'))
43
+ expires_at = parse_datetime(data.get('expires_at'))
44
+
45
+ result_url = data.get('result_url')
46
+ error = data.get('error')
47
+ execution_time_ms = data.get('execution_time_ms')
48
+ row_count = data.get('row_count')
49
+ position_in_queue = data.get('position_in_queue')
50
+ query_metadata = data.get('query_metadata')
51
+
52
+ return cls(
53
+ id=query_id,
54
+ query_text=query_text,
55
+ status=status,
56
+ mode=mode,
57
+ user_id=user_id,
58
+ created_at=created_at,
59
+ completed_at=completed_at,
60
+ result_url=result_url,
61
+ error=error,
62
+ execution_time_ms=execution_time_ms,
63
+ row_count=row_count,
64
+ position_in_queue=position_in_queue,
65
+ expires_at=expires_at,
66
+ query_metadata=query_metadata
67
+ )
68
+
69
+ @property
70
+ def is_complete(self) -> bool:
71
+ """Check if the query has completed (successfully or with error)."""
72
+ return self.status in ['COMPLETED', 'ERROR']
73
+
74
+ @property
75
+ def is_running(self) -> bool:
76
+ """Check if the query is currently running."""
77
+ return self.status == 'RUNNING'
78
+
79
+ @property
80
+ def is_queued(self) -> bool:
81
+ """Check if the query is queued."""
82
+ return self.status == 'QUEUED'
83
+
84
+ @property
85
+ def is_successful(self) -> bool:
86
+ """Check if the query completed successfully."""
87
+ return self.status == 'COMPLETED'
88
+
89
+ @property
90
+ def is_failed(self) -> bool:
91
+ """Check if the query failed."""
92
+ return self.status == 'ERROR'
93
+
94
+
95
+ @dataclass
96
+ class QueryResult:
97
+ """
98
+ Represents the result of a query, including the data and metadata.
99
+ """
100
+ query: Query
101
+ data: pd.DataFrame
102
+ execution_time_ms: Optional[int] = None
103
+ row_count: Optional[int] = None
104
+ column_count: Optional[int] = None
105
+
106
+ def to_csv(self, path: str, **kwargs) -> None:
107
+ """Save the query result to a CSV file."""
108
+ self.data.to_csv(path, **kwargs)
109
+
110
+ def to_parquet(self, path: str, **kwargs) -> None:
111
+ """Save the query result to a Parquet file."""
112
+ self.data.to_parquet(path, **kwargs)
113
+
114
+ def to_json(self, path: str = None, **kwargs) -> Optional[str]:
115
+ """
116
+ Convert the query result to JSON.
117
+ If path is provided, saves to file, otherwise returns a JSON string.
118
+ """
119
+ if path:
120
+ self.data.to_json(path, **kwargs)
121
+ return None
122
+ return self.data.to_json(**kwargs)
123
+
124
+ def head(self, n: int = 5) -> pd.DataFrame:
125
+ """Return the first n rows of the result."""
126
+ return self.data.head(n)
127
+
128
+ def tail(self, n: int = 5) -> pd.DataFrame:
129
+ """Return the last n rows of the result."""
130
+ return self.data.tail(n)
131
+
132
+ def describe(self) -> pd.DataFrame:
133
+ """Return summary statistics of the result."""
134
+ return self.data.describe()