From 358feaf258d5948d42eb7239f6bbf6ff3c9922a3 Mon Sep 17 00:00:00 2001 From: Sergey Fedoseev Date: Tue, 18 Jun 2024 09:07:27 +0400 Subject: [PATCH 1/5] Calculate checksum from local file if upload optimization succeeds (#3968) Co-authored-by: Alexei Mochalov --- api/python/quilt3/data_transfer.py | 144 +++++++----- api/python/quilt3/packages.py | 4 +- api/python/tests/integration/test_packages.py | 6 +- api/python/tests/test_data_transfer.py | 217 +++++++++++++++++- docs/CHANGELOG.md | 2 + 5 files changed, 311 insertions(+), 62 deletions(-) diff --git a/api/python/quilt3/data_transfer.py b/api/python/quilt3/data_transfer.py index 4973ced0810..f31c28cc93f 100644 --- a/api/python/quilt3/data_transfer.py +++ b/api/python/quilt3/data_transfer.py @@ -268,6 +268,10 @@ def get_checksum_chunksize(file_size: int) -> int: return chunksize +def is_mpu(file_size: int) -> bool: + return file_size >= CHECKSUM_MULTIPART_THRESHOLD + + _EMPTY_STRING_SHA256 = hashlib.sha256(b'').digest() @@ -303,7 +307,7 @@ def _copy_local_file(ctx: WorkerContext, size: int, src_path: str, dest_path: st def _upload_file(ctx: WorkerContext, size: int, src_path: str, dest_bucket: str, dest_key: str): s3_client = ctx.s3_client_provider.standard_client - if size < CHECKSUM_MULTIPART_THRESHOLD: + if not is_mpu(size): with ReadFileChunk.from_filename(src_path, 0, size, [ctx.progress]) as fd: resp = s3_client.put_object( Body=fd, @@ -460,7 +464,7 @@ def _copy_remote_file(ctx: WorkerContext, size: int, src_bucket: str, src_key: s s3_client = ctx.s3_client_provider.standard_client - if size < CHECKSUM_MULTIPART_THRESHOLD: + if not is_mpu(size): params: Dict[str, Any] = dict( CopySource=src_params, Bucket=dest_bucket, @@ -530,43 +534,62 @@ def upload_part(i, start, end): ctx.run(upload_part, i, start, end) -def _upload_or_copy_file(ctx: WorkerContext, size: int, src_path: str, dest_bucket: str, dest_path: str): +def _calculate_local_checksum(path: str, size: int): + chunksize = get_checksum_chunksize(size) + + part_hashes = [] + for start in range(0, size, chunksize): + end = min(start + chunksize, size) + part_hashes.append(_calculate_local_part_checksum(path, start, end - start)) + + return _make_checksum_from_parts(part_hashes) + + +def _reuse_remote_file(ctx: WorkerContext, size: int, src_path: str, dest_bucket: str, dest_path: str): # Optimization: check if the remote file already exists and has the right ETag, # and skip the upload. - if size >= UPLOAD_ETAG_OPTIMIZATION_THRESHOLD: - try: - params = dict(Bucket=dest_bucket, Key=dest_path) - s3_client = ctx.s3_client_provider.find_correct_client(S3Api.HEAD_OBJECT, dest_bucket, params) - resp = s3_client.head_object(**params, ChecksumMode='ENABLED') - except ClientError: - # Destination doesn't exist, so fall through to the normal upload. - pass - except S3NoValidClientError: - # S3ClientProvider can't currently distinguish between a user that has PUT but not LIST permissions and a - # user that has no permissions. If we can't find a valid client, proceed to the upload stage anyway. - pass - else: - # Check the ETag. - dest_size = resp['ContentLength'] - dest_etag = resp['ETag'] - dest_version_id = resp.get('VersionId') - if size == dest_size and resp.get('ServerSideEncryption') != 'aws:kms': - src_etag = _calculate_etag(src_path) - if src_etag == dest_etag: - # Nothing more to do. We should not attempt to copy the object because - # that would cause the "copy object to itself" error. - # TODO: Check SHA256 before checking ETag? - s3_checksum = resp.get('ChecksumSHA256') - if s3_checksum is None: - checksum = None - elif '-' in s3_checksum: - checksum, _ = s3_checksum.split('-', 1) - else: - checksum = _simple_s3_to_quilt_checksum(s3_checksum) - ctx.progress(size) - ctx.done(PhysicalKey(dest_bucket, dest_path, dest_version_id), checksum) - return # Optimization succeeded. + if size < UPLOAD_ETAG_OPTIMIZATION_THRESHOLD: + return None + try: + params = dict(Bucket=dest_bucket, Key=dest_path) + s3_client = ctx.s3_client_provider.find_correct_client(S3Api.HEAD_OBJECT, dest_bucket, params) + resp = s3_client.head_object(**params, ChecksumMode="ENABLED") + except ClientError: + # Destination doesn't exist, so fall through to the normal upload. + pass + except S3NoValidClientError: + # S3ClientProvider can't currently distinguish between a user that has PUT but not LIST permissions and a + # user that has no permissions. If we can't find a valid client, proceed to the upload stage anyway. + pass + else: + dest_size = resp["ContentLength"] + if dest_size != size: + return None + # TODO: we could check hashes of parts, to finish faster + s3_checksum = resp.get("ChecksumSHA256") + if s3_checksum is not None: + if "-" in s3_checksum: + checksum, num_parts_str = s3_checksum.split("-", 1) + num_parts = int(num_parts_str) + else: + checksum = _simple_s3_to_quilt_checksum(s3_checksum) + num_parts = None + expected_num_parts = math.ceil(size / get_checksum_chunksize(size)) if is_mpu(size) else None + if num_parts == expected_num_parts and checksum == _calculate_local_checksum(src_path, size): + return resp.get("VersionId"), checksum + elif resp.get("ServerSideEncryption") != "aws:kms" and resp["ETag"] == _calculate_etag(src_path): + return resp.get("VersionId"), _calculate_local_checksum(src_path, size) + + return None + +def _upload_or_reuse_file(ctx: WorkerContext, size: int, src_path: str, dest_bucket: str, dest_path: str): + result = _reuse_remote_file(ctx, size, src_path, dest_bucket, dest_path) + if result is not None: + dest_version_id, checksum = result + ctx.progress(size) + ctx.done(PhysicalKey(dest_bucket, dest_path, dest_version_id), checksum) + return # Optimization succeeded. # If the optimization didn't happen, do the normal upload. _upload_file(ctx, size, src_path, dest_bucket, dest_path) @@ -648,7 +671,7 @@ def done_callback(value, checksum): else: if dest.version_id: raise ValueError("Cannot set VersionId on destination") - _upload_or_copy_file(ctx, size, src.path, dest.bucket, dest.path) + _upload_or_reuse_file(ctx, size, src.path, dest.bucket, dest.path) else: if dest.is_local(): _download_file(ctx, size, src.bucket, src.path, src.version_id, dest.path) @@ -701,7 +724,7 @@ def _calculate_etag(file_path): """ size = pathlib.Path(file_path).stat().st_size with open(file_path, 'rb') as fd: - if size < CHECKSUM_MULTIPART_THRESHOLD: + if not is_mpu(size): contents = fd.read() etag = hashlib.md5(contents).hexdigest() else: @@ -970,6 +993,28 @@ def wrapper(*args, **kwargs): return wrapper +def _calculate_local_part_checksum(src: str, offset: int, length: int, callback=None) -> bytes: + hash_obj = hashlib.sha256() + bytes_remaining = length + with open(src, "rb") as fd: + fd.seek(offset) + while bytes_remaining > 0: + chunk = fd.read(min(s3_transfer_config.io_chunksize, bytes_remaining)) + if not chunk: + # Should not happen, but let's not get stuck in an infinite loop. + raise QuiltException("Unexpected end of file") + hash_obj.update(chunk) + if callback is not None: + callback(len(chunk)) + bytes_remaining -= len(chunk) + + return hash_obj.digest() + + +def _make_checksum_from_parts(parts: List[bytes]) -> str: + return binascii.b2a_base64(hashlib.sha256(b"".join(parts)).digest(), newline=False).decode() + + @retry(stop=stop_after_attempt(MAX_FIX_HASH_RETRIES), wait=wait_exponential(multiplier=1, min=1, max=10), retry=retry_if_result(lambda results: any(r is None or isinstance(r, Exception) for r in results)), @@ -990,21 +1035,10 @@ def _calculate_checksum_internal(src_list, sizes, results) -> List[bytes]: progress_update = with_lock(progress.update) def _process_url_part(src: PhysicalKey, offset: int, length: int): - hash_obj = hashlib.sha256() - if src.is_local(): - bytes_remaining = length - with open(src.path, 'rb') as fd: - fd.seek(offset) - while bytes_remaining > 0: - chunk = fd.read(min(s3_transfer_config.io_chunksize, bytes_remaining)) - if not chunk: - # Should not happen, but let's not get stuck in an infinite loop. - raise QuiltException("Unexpected end of file") - hash_obj.update(chunk) - progress_update(len(chunk)) - bytes_remaining -= len(chunk) + return _calculate_local_part_checksum(src.path, offset, length, progress_update) else: + hash_obj = hashlib.sha256() end = offset + length - 1 params = dict( Bucket=src.bucket, @@ -1026,7 +1060,7 @@ def _process_url_part(src: PhysicalKey, offset: int, length: int): except (ConnectionError, HTTPClientError, ReadTimeoutError) as ex: return ex - return hash_obj.digest() + return hash_obj.digest() futures: List[Tuple[int, List[Future]]] = [] @@ -1046,11 +1080,7 @@ def _process_url_part(src: PhysicalKey, offset: int, length: int): for idx, future_list in futures: future_results = [future.result() for future in future_list] exceptions = [ex for ex in future_results if isinstance(ex, Exception)] - if exceptions: - results[idx] = exceptions[0] - else: - hashes_hash = hashlib.sha256(b''.join(future_results)).digest() - results[idx] = binascii.b2a_base64(hashes_hash, newline=False).decode() + results[idx] = exceptions[0] if exceptions else _make_checksum_from_parts(future_results) finally: stopped = True for _, future_list in futures: diff --git a/api/python/quilt3/packages.py b/api/python/quilt3/packages.py index a9f5394b70d..342e59967bc 100644 --- a/api/python/quilt3/packages.py +++ b/api/python/quilt3/packages.py @@ -1540,9 +1540,9 @@ def check_hash_conficts(latest_hash): new_entry.hash = dict(type=SHA256_CHUNKED_HASH_NAME, value=checksum) pkg._set(logical_key, new_entry) - # Needed if the files already exist in S3, but were uploaded without ChecksumAlgorithm='SHA256'. + # Some entries may miss hash values (e.g because of selector_fn), so we need + # to fix them before calculating the top hash. pkg._fix_sha256() - top_hash = pkg._calculate_top_hash(pkg._meta, pkg.walk()) if dedupe and top_hash == latest_hash: diff --git a/api/python/tests/integration/test_packages.py b/api/python/tests/integration/test_packages.py index 84bcb1fd1a2..bcab2f2bab8 100644 --- a/api/python/tests/integration/test_packages.py +++ b/api/python/tests/integration/test_packages.py @@ -1914,10 +1914,11 @@ def test_push_selector_fn_false(self): selector_fn = mock.MagicMock(return_value=False) push_manifest_mock = self.patch_s3_registry('push_manifest') self.patch_s3_registry('shorten_top_hash', return_value='7a67ff4') - with patch('quilt3.packages.calculate_checksum', return_value=[('SHA256', "a" * 64)]): + with patch('quilt3.packages.calculate_checksum', return_value=["a" * 64]) as calculate_checksum_mock: pkg.push(pkg_name, registry=f's3://{dst_bucket}', selector_fn=selector_fn, force=True) selector_fn.assert_called_once_with(lk, pkg[lk]) + calculate_checksum_mock.assert_called_once_with([PhysicalKey(src_bucket, src_key, src_version)], [0]) push_manifest_mock.assert_called_once_with(pkg_name, mock.sentinel.top_hash, ANY) assert Package.load( BytesIO(push_manifest_mock.call_args[0][2]) @@ -1960,10 +1961,11 @@ def test_push_selector_fn_true(self): ) push_manifest_mock = self.patch_s3_registry('push_manifest') self.patch_s3_registry('shorten_top_hash', return_value='7a67ff4') - with patch('quilt3.packages.calculate_checksum', return_value=["a" * 64]): + with patch('quilt3.packages.calculate_checksum', return_value=[]) as calculate_checksum_mock: pkg.push(pkg_name, registry=f's3://{dst_bucket}', selector_fn=selector_fn, force=True) selector_fn.assert_called_once_with(lk, pkg[lk]) + calculate_checksum_mock.assert_called_once_with([], []) push_manifest_mock.assert_called_once_with(pkg_name, mock.sentinel.top_hash, ANY) assert Package.load( BytesIO(push_manifest_mock.call_args[0][2]) diff --git a/api/python/tests/test_data_transfer.py b/api/python/tests/test_data_transfer.py index 3c9549cb502..05a71597a98 100644 --- a/api/python/tests/test_data_transfer.py +++ b/api/python/tests/test_data_transfer.py @@ -280,7 +280,10 @@ def test_upload_large_file_etag_match(self): urls = data_transfer.copy_file_list([ (PhysicalKey.from_path(path), PhysicalKey.from_url('s3://example/large_file.npy'), path.stat().st_size), ]) - assert urls[0] == (PhysicalKey.from_url('s3://example/large_file.npy?versionId=v1'), None) + assert urls[0] == ( + PhysicalKey.from_url('s3://example/large_file.npy?versionId=v1'), + "IsygGcHBbQgZ3DCzdPy9+0od5VqDJjcW4R0mF2v/Bu8=", + ) def test_upload_large_file_etag_mismatch(self): path = DATA_DIR / 'large_file.npy' @@ -322,6 +325,218 @@ def test_upload_large_file_etag_mismatch(self): 'Ij4KFgr52goD5t0sRxnFb11mpjPL6E54qqnzc1hlUio=', ) + def test_upload_file_checksum_match(self): + path = DATA_DIR / 'large_file.npy' + assert path.stat().st_size < data_transfer.CHECKSUM_MULTIPART_THRESHOLD + + self.s3_stubber.add_response( + method='head_object', + service_response={ + 'ContentLength': path.stat().st_size, + 'ETag': '"123"', + 'VersionId': 'v1', + 'ChecksumSHA256': 'J+KTXLmOXrP7AmRZQQZWSj6DznTh7TbeeP6YbL1j+5w=', + }, + expected_params={ + 'Bucket': 'example', + 'Key': 'large_file.npy', + 'ChecksumMode': 'ENABLED', + } + ) + + urls = data_transfer.copy_file_list([ + (PhysicalKey.from_path(path), PhysicalKey.from_url('s3://example/large_file.npy'), path.stat().st_size), + ]) + assert urls[0] == ( + PhysicalKey.from_url('s3://example/large_file.npy?versionId=v1'), + "IsygGcHBbQgZ3DCzdPy9+0od5VqDJjcW4R0mF2v/Bu8=", + ) + + def test_upload_file_checksum_match_unexpected_parts(self): + path = DATA_DIR / 'large_file.npy' + assert path.stat().st_size < data_transfer.CHECKSUM_MULTIPART_THRESHOLD + + self.s3_stubber.add_response( + method='head_object', + service_response={ + 'ContentLength': path.stat().st_size, + 'ETag': '"123"', + 'VersionId': 'v1', + 'ChecksumSHA256': 'IsygGcHBbQgZ3DCzdPy9+0od5VqDJjcW4R0mF2v/Bu8=-1', + }, + expected_params={ + 'Bucket': 'example', + 'Key': 'large_file.npy', + 'ChecksumMode': 'ENABLED', + } + ) + + self.s3_stubber.add_response( + method='put_object', + service_response={ + 'VersionId': 'v2', + # b2a_base64(a2b_hex(b'0123456789abcdef0123456789abcdef')) + 'ChecksumSHA256': 'ASNFZ4mrze8BI0VniavN7w==', + }, + expected_params={ + 'Body': ANY, + 'Bucket': 'example', + 'Key': 'large_file.npy', + 'ChecksumAlgorithm': 'SHA256', + } + ) + + urls = data_transfer.copy_file_list([ + (PhysicalKey.from_path(path), PhysicalKey.from_url('s3://example/large_file.npy'), path.stat().st_size), + ]) + assert urls[0] == ( + PhysicalKey.from_url('s3://example/large_file.npy?versionId=v2'), + "Ij4KFgr52goD5t0sRxnFb11mpjPL6E54qqnzc1hlUio=", + ) + + def test_upload_file_checksum_multipart_match(self): + path = pathlib.Path("test-file") + path.write_bytes(bytes(data_transfer.CHECKSUM_MULTIPART_THRESHOLD)) + + self.s3_stubber.add_response( + method='head_object', + service_response={ + 'ContentLength': path.stat().st_size, + 'ETag': '"123"', + 'VersionId': 'v1', + 'ChecksumSHA256': 'MIsGKY+ykqN4CPj3gGGu4Gv03N7OWKWpsZqEf+OrGJs=-1', + }, + expected_params={ + 'Bucket': 'example', + 'Key': 'large_file.npy', + 'ChecksumMode': 'ENABLED', + } + ) + + urls = data_transfer.copy_file_list([ + (PhysicalKey.from_path(path), PhysicalKey.from_url('s3://example/large_file.npy'), path.stat().st_size), + ]) + assert urls[0] == ( + PhysicalKey.from_url('s3://example/large_file.npy?versionId=v1'), + "MIsGKY+ykqN4CPj3gGGu4Gv03N7OWKWpsZqEf+OrGJs=", + ) + + def test_upload_file_checksum_multipart_match_unexpected_parts(self): + path = pathlib.Path("test-file") + path.write_bytes(bytes(data_transfer.CHECKSUM_MULTIPART_THRESHOLD)) + + self.s3_stubber.add_response( + method='head_object', + service_response={ + 'ContentLength': path.stat().st_size, + 'ETag': '"123"', + 'VersionId': 'v1', + 'ChecksumSHA256': 'La6x82CVtEsxhBCz9Oi12Yncx7sCPRQmxJLasKMFPnQ=', + }, + expected_params={ + 'Bucket': 'example', + 'Key': 'large_file.npy', + 'ChecksumMode': 'ENABLED', + } + ) + + self.s3_stubber.add_response( + method='create_multipart_upload', + service_response={ + 'UploadId': '123' + }, + expected_params={ + 'Bucket': 'example', + 'Key': 'large_file.npy', + 'ChecksumAlgorithm': 'SHA256', + } + ) + self.s3_stubber.add_response( + method='upload_part', + service_response={ + 'ETag': '"123"', + 'ChecksumSHA256': 'La6x82CVtEsxhBCz9Oi12Yncx7sCPRQmxJLasKMFPnQ=', + }, + expected_params={ + 'Bucket': 'example', + 'Key': 'large_file.npy', + 'UploadId': '123', + 'Body': ANY, + 'PartNumber': 1, + 'ChecksumAlgorithm': 'SHA256', + } + ) + self.s3_stubber.add_response( + method='complete_multipart_upload', + service_response={ + 'ChecksumSHA256': "MIsGKY+ykqN4CPj3gGGu4Gv03N7OWKWpsZqEf+OrGJs=-1", + 'VersionId': 'v1', + }, + expected_params={ + 'Bucket': 'example', + 'Key': 'large_file.npy', + 'UploadId': '123', + 'MultipartUpload': { + 'Parts': [ + { + 'ETag': '"123"', + 'ChecksumSHA256': 'La6x82CVtEsxhBCz9Oi12Yncx7sCPRQmxJLasKMFPnQ=', + 'PartNumber': 1, + }, + ] + } + } + ) + + urls = data_transfer.copy_file_list([ + (PhysicalKey.from_path(path), PhysicalKey.from_url('s3://example/large_file.npy'), path.stat().st_size), + ]) + assert urls[0] == ( + PhysicalKey.from_url('s3://example/large_file.npy?versionId=v1'), + "MIsGKY+ykqN4CPj3gGGu4Gv03N7OWKWpsZqEf+OrGJs=", + ) + + def test_upload_file_size_mismatch(self): + path = DATA_DIR / 'large_file.npy' + + self.s3_stubber.add_response( + method='head_object', + service_response={ + 'ContentLength': path.stat().st_size + 1, + 'ETag': data_transfer._calculate_etag(path), + 'VersionId': 'v1', + 'ChecksumSHA256': 'IsygGcHBbQgZ3DCzdPy9+0od5VqDJjcW4R0mF2v/Bu8=-1', + }, + expected_params={ + 'Bucket': 'example', + 'Key': 'large_file.npy', + 'ChecksumMode': 'ENABLED', + } + ) + + self.s3_stubber.add_response( + method='put_object', + service_response={ + 'VersionId': 'v2', + # b2a_base64(a2b_hex(b'0123456789abcdef0123456789abcdef')) + 'ChecksumSHA256': 'ASNFZ4mrze8BI0VniavN7w==', + }, + expected_params={ + 'Body': ANY, + 'Bucket': 'example', + 'Key': 'large_file.npy', + 'ChecksumAlgorithm': 'SHA256', + } + ) + + urls = data_transfer.copy_file_list([ + (PhysicalKey.from_path(path), PhysicalKey.from_url('s3://example/large_file.npy'), path.stat().st_size), + ]) + assert urls[0] == ( + PhysicalKey.from_url('s3://example/large_file.npy?versionId=v2'), + "Ij4KFgr52goD5t0sRxnFb11mpjPL6E54qqnzc1hlUio=", + ) + def test_multipart_upload(self): name = 'very_large_file.bin' path = pathlib.Path(name) diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index e3d2fae8745..82528906f1d 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -18,6 +18,8 @@ Entries inside each section should be ordered by type: ## Python API * [Removed] Drop Python 3.8 support ([#3993](https://github.com/quiltdata/quilt/pull/3993)) +* [Fixed] If upload optimization during `push()` succeeds the checksum is calculated from local file instead of remote file ([#3968](https://github.com/quiltdata/quilt/pull/3968)) +* [Changed] Upload optimization check now tries to use S3 SHA-256 checksum and falls back to ETag ([#3968](https://github.com/quiltdata/quilt/pull/3968)) ## CLI From 5b26de49b15904820b14ef85e77a2f5fe1d9fdc4 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 18 Jun 2024 05:08:01 +0000 Subject: [PATCH 2/5] Bump urllib3 from 1.26.18 to 1.26.19 in /lambdas/status_reports (#4005) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- lambdas/status_reports/requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lambdas/status_reports/requirements.txt b/lambdas/status_reports/requirements.txt index a8a25aa6253..75ce9b82060 100644 --- a/lambdas/status_reports/requirements.txt +++ b/lambdas/status_reports/requirements.txt @@ -44,7 +44,7 @@ six==1.16.0 # via python-dateutil typing-extensions==4.3.0 # via aioitertools -urllib3==1.26.18 +urllib3==1.26.19 # via botocore wrapt==1.14.1 # via aiobotocore From 65f3e3df557938e2f7239544321c4158694f66bb Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 18 Jun 2024 09:26:45 +0400 Subject: [PATCH 3/5] Bump urllib3 from 1.26.18 to 1.26.19 in /lambdas/pkgevents (#4006) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- lambdas/pkgevents/requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lambdas/pkgevents/requirements.txt b/lambdas/pkgevents/requirements.txt index 3fdd961121b..5e9065f0043 100644 --- a/lambdas/pkgevents/requirements.txt +++ b/lambdas/pkgevents/requirements.txt @@ -4,4 +4,4 @@ jmespath==0.10.0 python-dateutil==2.8.2 s3transfer==0.4.2 six==1.16.0 -urllib3==1.26.18 +urllib3==1.26.19 From e52303c8fdb778d0864786ca100432b645d66abe Mon Sep 17 00:00:00 2001 From: Sergey Fedoseev Date: Tue, 18 Jun 2024 09:51:43 +0400 Subject: [PATCH 4/5] GraphQL-based quilt3.admin API (#3990) Co-authored-by: Alexei Mochalov --- .../workflows/test-quilt3-admin-codegen.yaml | 36 + api/python/.gitattributes | 1 + api/python/quilt3-admin/.python-version | 1 + api/python/quilt3-admin/README.md | 10 + api/python/quilt3-admin/base_client.py | 211 +++++ api/python/quilt3-admin/exceptions.py | 87 ++ api/python/quilt3-admin/pyproject.toml | 21 + api/python/quilt3-admin/queries.graphql | 189 ++++ api/python/quilt3-admin/requirements.in | 1 + api/python/quilt3-admin/requirements.txt | 62 ++ api/python/quilt3/admin.py | 56 -- api/python/quilt3/admin/__init__.py | 9 + .../quilt3/admin/_graphql_client/__init__.py | 201 +++++ .../admin/_graphql_client/base_client.py | 213 +++++ .../admin/_graphql_client/base_model.py | 29 + .../quilt3/admin/_graphql_client/client.py | 854 ++++++++++++++++++ .../quilt3/admin/_graphql_client/enums.py | 3 + .../admin/_graphql_client/exceptions.py | 89 ++ .../quilt3/admin/_graphql_client/fragments.py | 87 ++ .../admin/_graphql_client/input_types.py | 16 + .../admin/_graphql_client/roles_list.py | 29 + .../admin/_graphql_client/users_add_roles.py | 47 + .../admin/_graphql_client/users_create.py | 42 + .../admin/_graphql_client/users_delete.py | 47 + .../quilt3/admin/_graphql_client/users_get.py | 28 + .../admin/_graphql_client/users_list.py | 28 + .../_graphql_client/users_remove_roles.py | 47 + .../_graphql_client/users_reset_password.py | 49 + .../admin/_graphql_client/users_set_active.py | 47 + .../admin/_graphql_client/users_set_admin.py | 47 + .../admin/_graphql_client/users_set_email.py | 47 + .../admin/_graphql_client/users_set_role.py | 47 + api/python/quilt3/admin/exceptions.py | 9 + api/python/quilt3/admin/roles.py | 10 + api/python/quilt3/admin/types.py | 39 + api/python/quilt3/admin/users.py | 165 ++++ api/python/quilt3/admin/util.py | 15 + api/python/setup.py | 1 + api/python/tests/test_admin_api.py | 326 +++++++ api/python/tests/test_api.py | 15 - docs/CHANGELOG.md | 2 + docs/api-reference/Admin.md | 129 ++- gendocs/pydocmd.yml | 4 +- pylintrc | 3 + setup.cfg | 2 + shared/graphql/schema.graphql | 76 ++ 46 files changed, 3391 insertions(+), 86 deletions(-) create mode 100644 .github/workflows/test-quilt3-admin-codegen.yaml create mode 100644 api/python/.gitattributes create mode 100644 api/python/quilt3-admin/.python-version create mode 100644 api/python/quilt3-admin/README.md create mode 100644 api/python/quilt3-admin/base_client.py create mode 100644 api/python/quilt3-admin/exceptions.py create mode 100644 api/python/quilt3-admin/pyproject.toml create mode 100644 api/python/quilt3-admin/queries.graphql create mode 100644 api/python/quilt3-admin/requirements.in create mode 100644 api/python/quilt3-admin/requirements.txt delete mode 100644 api/python/quilt3/admin.py create mode 100644 api/python/quilt3/admin/__init__.py create mode 100644 api/python/quilt3/admin/_graphql_client/__init__.py create mode 100644 api/python/quilt3/admin/_graphql_client/base_client.py create mode 100644 api/python/quilt3/admin/_graphql_client/base_model.py create mode 100644 api/python/quilt3/admin/_graphql_client/client.py create mode 100644 api/python/quilt3/admin/_graphql_client/enums.py create mode 100644 api/python/quilt3/admin/_graphql_client/exceptions.py create mode 100644 api/python/quilt3/admin/_graphql_client/fragments.py create mode 100644 api/python/quilt3/admin/_graphql_client/input_types.py create mode 100644 api/python/quilt3/admin/_graphql_client/roles_list.py create mode 100644 api/python/quilt3/admin/_graphql_client/users_add_roles.py create mode 100644 api/python/quilt3/admin/_graphql_client/users_create.py create mode 100644 api/python/quilt3/admin/_graphql_client/users_delete.py create mode 100644 api/python/quilt3/admin/_graphql_client/users_get.py create mode 100644 api/python/quilt3/admin/_graphql_client/users_list.py create mode 100644 api/python/quilt3/admin/_graphql_client/users_remove_roles.py create mode 100644 api/python/quilt3/admin/_graphql_client/users_reset_password.py create mode 100644 api/python/quilt3/admin/_graphql_client/users_set_active.py create mode 100644 api/python/quilt3/admin/_graphql_client/users_set_admin.py create mode 100644 api/python/quilt3/admin/_graphql_client/users_set_email.py create mode 100644 api/python/quilt3/admin/_graphql_client/users_set_role.py create mode 100644 api/python/quilt3/admin/exceptions.py create mode 100644 api/python/quilt3/admin/roles.py create mode 100644 api/python/quilt3/admin/types.py create mode 100644 api/python/quilt3/admin/users.py create mode 100644 api/python/quilt3/admin/util.py create mode 100644 api/python/tests/test_admin_api.py diff --git a/.github/workflows/test-quilt3-admin-codegen.yaml b/.github/workflows/test-quilt3-admin-codegen.yaml new file mode 100644 index 00000000000..d98719c8223 --- /dev/null +++ b/.github/workflows/test-quilt3-admin-codegen.yaml @@ -0,0 +1,36 @@ +name: Test quilt3.admin code generation + +on: + push: + paths: + - '.github/workflows/test-quilt3-admin-codegen.yaml' + - 'shared/graphql/schema.graphql' + - 'api/python/quilt3-admin/**' + - 'api/python/quilt3/admin/_graphql_client/**' + pull_request: + paths: + - '.github/workflows/test-quilt3-admin-codegen.yaml' + - 'shared/graphql/schema.graphql' + - 'api/python/quilt3-admin/**' + - 'api/python/quilt3/admin/_graphql_client/**' + merge_group: + +jobs: + test-quilt3-admin-codegen: + name: test quilt3.admin generated code is up-to-date + runs-on: ubuntu-latest + defaults: + run: + working-directory: ./api/python/quilt3-admin + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version-file: 'api/python/quilt3-admin/.python-version' + cache: 'pip' + cache-dependency-path: 'api/python/quilt3-admin/requirements.txt' + - run: pip install -r requirements.txt + - run: rm -r ../quilt3/admin/_graphql_client + - run: ariadne-codegen + - name: Check for changes + run: git diff --exit-code diff --git a/api/python/.gitattributes b/api/python/.gitattributes new file mode 100644 index 00000000000..aa9cc2f3e99 --- /dev/null +++ b/api/python/.gitattributes @@ -0,0 +1 @@ +quilt3/admin/_graphql_client/** linguist-generated diff --git a/api/python/quilt3-admin/.python-version b/api/python/quilt3-admin/.python-version new file mode 100644 index 00000000000..e4fba218358 --- /dev/null +++ b/api/python/quilt3-admin/.python-version @@ -0,0 +1 @@ +3.12 diff --git a/api/python/quilt3-admin/README.md b/api/python/quilt3-admin/README.md new file mode 100644 index 00000000000..9e6c66d280d --- /dev/null +++ b/api/python/quilt3-admin/README.md @@ -0,0 +1,10 @@ +# quilt3.admin GraphQL code generation + +```sh +python -m venv venv +python -m pip install -r requirements.txt +ariadne-codegen +``` + +This will generate GraphQL client in `api/python/quilt3/admin/_graphql_client/` using +GraphQL queries from `queries.graphql`. diff --git a/api/python/quilt3-admin/base_client.py b/api/python/quilt3-admin/base_client.py new file mode 100644 index 00000000000..a3346665914 --- /dev/null +++ b/api/python/quilt3-admin/base_client.py @@ -0,0 +1,211 @@ +# This is +# https://github.com/mirumee/ariadne-codegen/blob/5bfd63c5e7e3a8cc5293eb94deee638b7adab98d/ariadne_codegen/client_generators/dependencies/base_client.py +# modified to use our requests session instead of httpx. +# pylint: disable=relative-beyond-top-level +import json +from typing import IO, Any, Dict, List, Optional, Tuple, TypeVar, cast + +import requests +from pydantic import BaseModel +from pydantic_core import to_jsonable_python + +from quilt3 import session + +from .base_model import UNSET, Upload +from .exceptions import ( + GraphQLClientGraphQLMultiError, + GraphQLClientHttpError, + GraphQLClientInvalidResponseError, +) + +Self = TypeVar("Self", bound="BaseClient") + + +class BaseClient: + def __init__( + self, + ) -> None: + self.url = session.get_registry_url() + "/graphql" + + self.http_client = session.get_session() + + def __enter__(self: Self) -> Self: + return self + + def __exit__( + self, + exc_type: object, + exc_val: object, + exc_tb: object, + ) -> None: + self.http_client.close() + + def execute( + self, + query: str, + operation_name: Optional[str] = None, + variables: Optional[Dict[str, Any]] = None, + **kwargs: Any, + ) -> requests.Response: + processed_variables, files, files_map = self._process_variables(variables) + + if files and files_map: + return self._execute_multipart( + query=query, + operation_name=operation_name, + variables=processed_variables, + files=files, + files_map=files_map, + **kwargs, + ) + + return self._execute_json( + query=query, + operation_name=operation_name, + variables=processed_variables, + **kwargs, + ) + + def get_data(self, response: requests.Response) -> Dict[str, Any]: + if not 200 <= response.status_code < 300: + raise GraphQLClientHttpError( + status_code=response.status_code, response=response + ) + + try: + response_json = response.json() + except ValueError as exc: + raise GraphQLClientInvalidResponseError(response=response) from exc + + if (not isinstance(response_json, dict)) or ( + "data" not in response_json and "errors" not in response_json + ): + raise GraphQLClientInvalidResponseError(response=response) + + data = response_json.get("data") + errors = response_json.get("errors") + + if errors: + raise GraphQLClientGraphQLMultiError.from_errors_dicts( + errors_dicts=errors, data=data + ) + + return cast(Dict[str, Any], data) + + def _process_variables( + self, variables: Optional[Dict[str, Any]] + ) -> Tuple[ + Dict[str, Any], Dict[str, Tuple[str, IO[bytes], str]], Dict[str, List[str]] + ]: + if not variables: + return {}, {}, {} + + serializable_variables = self._convert_dict_to_json_serializable(variables) + return self._get_files_from_variables(serializable_variables) + + def _convert_dict_to_json_serializable( + self, dict_: Dict[str, Any] + ) -> Dict[str, Any]: + return { + key: self._convert_value(value) + for key, value in dict_.items() + if value is not UNSET + } + + def _convert_value(self, value: Any) -> Any: + if isinstance(value, BaseModel): + return value.model_dump(by_alias=True, exclude_unset=True) + if isinstance(value, list): + return [self._convert_value(item) for item in value] + return value + + def _get_files_from_variables( + self, variables: Dict[str, Any] + ) -> Tuple[ + Dict[str, Any], Dict[str, Tuple[str, IO[bytes], str]], Dict[str, List[str]] + ]: + files_map: Dict[str, List[str]] = {} + files_list: List[Upload] = [] + + def separate_files(path: str, obj: Any) -> Any: + if isinstance(obj, list): + nulled_list = [] + for index, value in enumerate(obj): + value = separate_files(f"{path}.{index}", value) + nulled_list.append(value) + return nulled_list + + if isinstance(obj, dict): + nulled_dict = {} + for key, value in obj.items(): + value = separate_files(f"{path}.{key}", value) + nulled_dict[key] = value + return nulled_dict + + if isinstance(obj, Upload): + if obj in files_list: + file_index = files_list.index(obj) + files_map[str(file_index)].append(path) + else: + file_index = len(files_list) + files_list.append(obj) + files_map[str(file_index)] = [path] + return None + + return obj + + nulled_variables = separate_files("variables", variables) + files: Dict[str, Tuple[str, IO[bytes], str]] = { + str(i): (file_.filename, cast(IO[bytes], file_.content), file_.content_type) + for i, file_ in enumerate(files_list) + } + return nulled_variables, files, files_map + + def _execute_multipart( + self, + query: str, + operation_name: Optional[str], + variables: Dict[str, Any], + files: Dict[str, Tuple[str, IO[bytes], str]], + files_map: Dict[str, List[str]], + **kwargs: Any, + ) -> requests.Response: + data = { + "operations": json.dumps( + { + "query": query, + "operationName": operation_name, + "variables": variables, + }, + default=to_jsonable_python, + ), + "map": json.dumps(files_map, default=to_jsonable_python), + } + + return self.http_client.post(url=self.url, data=data, files=files, **kwargs) + + def _execute_json( + self, + query: str, + operation_name: Optional[str], + variables: Dict[str, Any], + **kwargs: Any, + ) -> requests.Response: + headers: Dict[str, str] = {"Content-Type": "application/json"} + headers.update(kwargs.get("headers", {})) + + merged_kwargs: Dict[str, Any] = kwargs.copy() + merged_kwargs["headers"] = headers + + return self.http_client.post( + url=self.url, + data=json.dumps( + { + "query": query, + "operationName": operation_name, + "variables": variables, + }, + default=to_jsonable_python, + ), + **merged_kwargs, + ) diff --git a/api/python/quilt3-admin/exceptions.py b/api/python/quilt3-admin/exceptions.py new file mode 100644 index 00000000000..0876664a226 --- /dev/null +++ b/api/python/quilt3-admin/exceptions.py @@ -0,0 +1,87 @@ +# This is +# https://github.com/mirumee/ariadne-codegen/blob/5bfd63c5e7e3a8cc5293eb94deee638b7adab98d/ariadne_codegen/client_generators/dependencies/exceptions.py +# modified to use our requests instead of httpx. +# pylint: disable=super-init-not-called +from typing import Any, Dict, List, Optional, Union + +import requests + + +class GraphQLClientError(Exception): + """Base exception.""" + + +class GraphQLClientHttpError(GraphQLClientError): + def __init__(self, status_code: int, response: requests.Response) -> None: + self.status_code = status_code + self.response = response + + def __str__(self) -> str: + return f"HTTP status code: {self.status_code}" + + +class GraphQLClientInvalidResponseError(GraphQLClientError): + def __init__(self, response: requests.Response) -> None: + self.response = response + + def __str__(self) -> str: + return "Invalid response format." + + +class GraphQLClientGraphQLError(GraphQLClientError): + def __init__( + self, + message: str, + locations: Optional[List[Dict[str, int]]] = None, + path: Optional[List[str]] = None, + extensions: Optional[Dict[str, object]] = None, + orginal: Optional[Dict[str, object]] = None, + ): + self.message = message + self.locations = locations + self.path = path + self.extensions = extensions + self.orginal = orginal + + def __str__(self) -> str: + return self.message + + @classmethod + def from_dict(cls, error: Dict[str, Any]) -> "GraphQLClientGraphQLError": + return cls( + message=error["message"], + locations=error.get("locations"), + path=error.get("path"), + extensions=error.get("extensions"), + orginal=error, + ) + + +class GraphQLClientGraphQLMultiError(GraphQLClientError): + def __init__( + self, + errors: List[GraphQLClientGraphQLError], + data: Optional[Dict[str, Any]] = None, + ): + self.errors = errors + self.data = data + + def __str__(self) -> str: + return "; ".join(str(e) for e in self.errors) + + @classmethod + def from_errors_dicts( + cls, errors_dicts: List[Dict[str, Any]], data: Optional[Dict[str, Any]] = None + ) -> "GraphQLClientGraphQLMultiError": + return cls( + errors=[GraphQLClientGraphQLError.from_dict(e) for e in errors_dicts], + data=data, + ) + + +class GraphQLClientInvalidMessageFormat(GraphQLClientError): + def __init__(self, message: Union[str, bytes]) -> None: + self.message = message + + def __str__(self) -> str: + return "Invalid message format." diff --git a/api/python/quilt3-admin/pyproject.toml b/api/python/quilt3-admin/pyproject.toml new file mode 100644 index 00000000000..a43cf7442ad --- /dev/null +++ b/api/python/quilt3-admin/pyproject.toml @@ -0,0 +1,21 @@ +[tool.ariadne-codegen] +schema_path = "../../../shared/graphql/schema.graphql" +queries_path = "queries.graphql" +target_package_path = "../quilt3/admin/" +target_package_name = "_graphql_client" +files_to_include = [ + "exceptions.py", +] +async_client = false +base_client_file_path = "base_client.py" +base_client_name = "BaseClient" +include_all_inputs = false +include_all_enums = false +plugins = [ + "ariadne_codegen.contrib.shorter_results.ShorterResultsPlugin", + "ariadne_codegen.contrib.shorter_results.ShorterResultsPlugin", + "ariadne_codegen.contrib.shorter_results.ShorterResultsPlugin", +] + +[tool.ariadne-codegen.scalars.Datetime] +type = "datetime.datetime" diff --git a/api/python/quilt3-admin/queries.graphql b/api/python/quilt3-admin/queries.graphql new file mode 100644 index 00000000000..4947f7f53ba --- /dev/null +++ b/api/python/quilt3-admin/queries.graphql @@ -0,0 +1,189 @@ +fragment UnmanagedRoleSelection on UnmanagedRole { + id + name + arn +} +fragment ManagedRoleSelection on ManagedRole { + id + name + arn +} +fragment RoleSelection on Role { + __typename + ...UnmanagedRoleSelection + ...ManagedRoleSelection +} +fragment UserSelection on User { + name + email + dateJoined + lastLogin + isActive + isAdmin + isSsoOnly + isService + role { + ...RoleSelection + } + extraRoles { + ...RoleSelection + } +} +fragment UserMutationSelection on UserResult { + ...UserSelection + ...InvalidInputSelection + ...OperationErrorSelection +} +fragment InvalidInputSelection on InvalidInput { + errors { + path + message + name + context + } +} +fragment OperationErrorSelection on OperationError { + message + name + context +} + +query rolesList { + roles { + ...RoleSelection + } +} + +query usersGet($name: String!) { + admin { + user { + get(name: $name) { + ...UserSelection + } + } + } +} + +query usersList { + admin { + user { + list { + ...UserSelection + } + } + } +} + +mutation usersCreate($input: UserInput!) { + admin { + user { + create(input: $input) { + ...UserMutationSelection + } + } + } +} + +mutation usersDelete($name: String!) { + admin { + user { + mutate(name: $name) { + delete { + ...InvalidInputSelection + ...OperationErrorSelection + } + } + } + } +} + +mutation usersSetEmail($email: String!, $name: String!) { + admin { + user { + mutate(name: $name) { + setEmail(email: $email) { + ...UserMutationSelection + } + } + } + } +} + +mutation usersSetAdmin($name: String!, $admin: Boolean!) { + admin { + user { + mutate(name: $name) { + setAdmin(admin: $admin) { + ...UserMutationSelection + } + } + } + } +} + +mutation usersSetActive($active: Boolean!, $name: String!) { + admin { + user { + mutate(name: $name) { + setActive(active: $active) { + ...UserMutationSelection + } + } + } + } +} + +mutation usersResetPassword($name: String!) { + admin { + user { + mutate(name: $name) { + resetPassword { + ...InvalidInputSelection + ...OperationErrorSelection + } + } + } + } +} + +mutation usersSetRole($name: String!, $role: String!, $extraRoles: [String!], $append: Boolean!) { + admin { + user { + mutate(name: $name) { + setRole(role: $role, extraRoles: $extraRoles, append: $append) { + ...UserSelection + ...InvalidInputSelection + ...OperationErrorSelection + } + } + } + } +} + +mutation usersAddRoles($name: String!, $roles: [String!]!) { + admin { + user { + mutate(name: $name) { + addRoles(roles: $roles) { + ...UserSelection + ...InvalidInputSelection + ...OperationErrorSelection + } + } + } + } +} + +mutation usersRemoveRoles($name: String!, $roles: [String!]!, $fallback: String) { + admin { + user { + mutate(name: $name) { + removeRoles(roles: $roles, fallback: $fallback) { + ...UserSelection + ...InvalidInputSelection + ...OperationErrorSelection + } + } + } + } +} diff --git a/api/python/quilt3-admin/requirements.in b/api/python/quilt3-admin/requirements.in new file mode 100644 index 00000000000..2f122266b8e --- /dev/null +++ b/api/python/quilt3-admin/requirements.in @@ -0,0 +1 @@ +ariadne-codegen diff --git a/api/python/quilt3-admin/requirements.txt b/api/python/quilt3-admin/requirements.txt new file mode 100644 index 00000000000..31f9f12a913 --- /dev/null +++ b/api/python/quilt3-admin/requirements.txt @@ -0,0 +1,62 @@ +# +# This file is autogenerated by pip-compile with Python 3.12 +# by the following command: +# +# pip-compile requirements.in +# +annotated-types==0.7.0 + # via pydantic +anyio==4.4.0 + # via httpx +ariadne-codegen==0.13.0 + # via -r requirements.in +autoflake==2.3.1 + # via ariadne-codegen +black==24.4.2 + # via ariadne-codegen +certifi==2024.6.2 + # via + # httpcore + # httpx +click==8.1.7 + # via + # ariadne-codegen + # black +graphql-core==3.2.3 + # via ariadne-codegen +h11==0.14.0 + # via httpcore +httpcore==1.0.5 + # via httpx +httpx==0.27.0 + # via ariadne-codegen +idna==3.7 + # via + # anyio + # httpx +isort==5.13.2 + # via ariadne-codegen +mypy-extensions==1.0.0 + # via black +packaging==24.1 + # via black +pathspec==0.12.1 + # via black +platformdirs==4.2.2 + # via black +pydantic==2.7.3 + # via ariadne-codegen +pydantic-core==2.18.4 + # via pydantic +pyflakes==3.2.0 + # via autoflake +sniffio==1.3.1 + # via + # anyio + # httpx +toml==0.10.2 + # via ariadne-codegen +typing-extensions==4.12.2 + # via + # pydantic + # pydantic-core diff --git a/api/python/quilt3/admin.py b/api/python/quilt3/admin.py deleted file mode 100644 index 6bdae483c74..00000000000 --- a/api/python/quilt3/admin.py +++ /dev/null @@ -1,56 +0,0 @@ -"""APIs for Quilt administrators. 'Registry' refers to Quilt stack backend services, including identity management.""" -import typing as T - -from .session import get_registry_url, get_session - - -def create_user(*, username: str, email: str): - """ - Create a new user in the registry. - - Required parameters: - username (str): Username of user to create. - email (str): Email of user to create. - """ - session = get_session() - response = session.post( - get_registry_url() + "/api/users/create", - json={ - "username": username, - "email": email, - }, - ) - - -def delete_user(*, username: str): - """ - Delete user from the registry. - - Required parameters: - username (str): Username of user to delete. - """ - session = get_session() - response = session.post( - get_registry_url() + "/api/users/delete", - json={ - "username": username, - }, - ) - - -def set_role(*, username: str, role_name: T.Optional[str]): - """ - Set the named Quilt role for a user. - - Required parameters: - username (str): Username of user to update. - role_name (str): Quilt role name assign to the user. Set a `None` value to unassign the role. - """ - session = get_session() - session.post( - get_registry_url() + "/api/users/set_role", - json={ - "username": username, - "role": role_name or "", - }, - ) diff --git a/api/python/quilt3/admin/__init__.py b/api/python/quilt3/admin/__init__.py new file mode 100644 index 00000000000..3f70b0f7777 --- /dev/null +++ b/api/python/quilt3/admin/__init__.py @@ -0,0 +1,9 @@ +""" +APIs for Quilt administrators. 'Registry' refers to Quilt stack backend services, including identity management. +""" + +# This wraps code generated by aridne-codegen to provide a more user-friendly API. + +from . import roles, users +from .exceptions import Quilt3AdminError, UserNotFoundError +from .types import ManagedRole, UnmanagedRole, User diff --git a/api/python/quilt3/admin/_graphql_client/__init__.py b/api/python/quilt3/admin/_graphql_client/__init__.py new file mode 100644 index 00000000000..399900def00 --- /dev/null +++ b/api/python/quilt3/admin/_graphql_client/__init__.py @@ -0,0 +1,201 @@ +# Generated by ariadne-codegen + +from .base_client import BaseClient +from .base_model import BaseModel, Upload +from .client import Client +from .fragments import ( + InvalidInputSelection, + InvalidInputSelectionErrors, + ManagedRoleSelection, + OperationErrorSelection, + UnmanagedRoleSelection, + UserSelection, + UserSelectionExtraRolesManagedRole, + UserSelectionExtraRolesUnmanagedRole, + UserSelectionRoleManagedRole, + UserSelectionRoleUnmanagedRole, +) +from .input_types import UserInput +from .roles_list import ( + RolesList, + RolesListRolesManagedRole, + RolesListRolesUnmanagedRole, +) +from .users_add_roles import ( + UsersAddRoles, + UsersAddRolesAdmin, + UsersAddRolesAdminUser, + UsersAddRolesAdminUserMutate, + UsersAddRolesAdminUserMutateAddRolesInvalidInput, + UsersAddRolesAdminUserMutateAddRolesOperationError, + UsersAddRolesAdminUserMutateAddRolesUser, +) +from .users_create import ( + UsersCreate, + UsersCreateAdmin, + UsersCreateAdminUser, + UsersCreateAdminUserCreateInvalidInput, + UsersCreateAdminUserCreateOperationError, + UsersCreateAdminUserCreateUser, +) +from .users_delete import ( + UsersDelete, + UsersDeleteAdmin, + UsersDeleteAdminUser, + UsersDeleteAdminUserMutate, + UsersDeleteAdminUserMutateDeleteInvalidInput, + UsersDeleteAdminUserMutateDeleteOk, + UsersDeleteAdminUserMutateDeleteOperationError, +) +from .users_get import UsersGet, UsersGetAdmin, UsersGetAdminUser, UsersGetAdminUserGet +from .users_list import ( + UsersList, + UsersListAdmin, + UsersListAdminUser, + UsersListAdminUserList, +) +from .users_remove_roles import ( + UsersRemoveRoles, + UsersRemoveRolesAdmin, + UsersRemoveRolesAdminUser, + UsersRemoveRolesAdminUserMutate, + UsersRemoveRolesAdminUserMutateRemoveRolesInvalidInput, + UsersRemoveRolesAdminUserMutateRemoveRolesOperationError, + UsersRemoveRolesAdminUserMutateRemoveRolesUser, +) +from .users_reset_password import ( + UsersResetPassword, + UsersResetPasswordAdmin, + UsersResetPasswordAdminUser, + UsersResetPasswordAdminUserMutate, + UsersResetPasswordAdminUserMutateResetPasswordInvalidInput, + UsersResetPasswordAdminUserMutateResetPasswordOk, + UsersResetPasswordAdminUserMutateResetPasswordOperationError, +) +from .users_set_active import ( + UsersSetActive, + UsersSetActiveAdmin, + UsersSetActiveAdminUser, + UsersSetActiveAdminUserMutate, + UsersSetActiveAdminUserMutateSetActiveInvalidInput, + UsersSetActiveAdminUserMutateSetActiveOperationError, + UsersSetActiveAdminUserMutateSetActiveUser, +) +from .users_set_admin import ( + UsersSetAdmin, + UsersSetAdminAdmin, + UsersSetAdminAdminUser, + UsersSetAdminAdminUserMutate, + UsersSetAdminAdminUserMutateSetAdminInvalidInput, + UsersSetAdminAdminUserMutateSetAdminOperationError, + UsersSetAdminAdminUserMutateSetAdminUser, +) +from .users_set_email import ( + UsersSetEmail, + UsersSetEmailAdmin, + UsersSetEmailAdminUser, + UsersSetEmailAdminUserMutate, + UsersSetEmailAdminUserMutateSetEmailInvalidInput, + UsersSetEmailAdminUserMutateSetEmailOperationError, + UsersSetEmailAdminUserMutateSetEmailUser, +) +from .users_set_role import ( + UsersSetRole, + UsersSetRoleAdmin, + UsersSetRoleAdminUser, + UsersSetRoleAdminUserMutate, + UsersSetRoleAdminUserMutateSetRoleInvalidInput, + UsersSetRoleAdminUserMutateSetRoleOperationError, + UsersSetRoleAdminUserMutateSetRoleUser, +) + +__all__ = [ + "BaseClient", + "BaseModel", + "Client", + "InvalidInputSelection", + "InvalidInputSelectionErrors", + "ManagedRoleSelection", + "OperationErrorSelection", + "RolesList", + "RolesListRolesManagedRole", + "RolesListRolesUnmanagedRole", + "UnmanagedRoleSelection", + "Upload", + "UserInput", + "UserSelection", + "UserSelectionExtraRolesManagedRole", + "UserSelectionExtraRolesUnmanagedRole", + "UserSelectionRoleManagedRole", + "UserSelectionRoleUnmanagedRole", + "UsersAddRoles", + "UsersAddRolesAdmin", + "UsersAddRolesAdminUser", + "UsersAddRolesAdminUserMutate", + "UsersAddRolesAdminUserMutateAddRolesInvalidInput", + "UsersAddRolesAdminUserMutateAddRolesOperationError", + "UsersAddRolesAdminUserMutateAddRolesUser", + "UsersCreate", + "UsersCreateAdmin", + "UsersCreateAdminUser", + "UsersCreateAdminUserCreateInvalidInput", + "UsersCreateAdminUserCreateOperationError", + "UsersCreateAdminUserCreateUser", + "UsersDelete", + "UsersDeleteAdmin", + "UsersDeleteAdminUser", + "UsersDeleteAdminUserMutate", + "UsersDeleteAdminUserMutateDeleteInvalidInput", + "UsersDeleteAdminUserMutateDeleteOk", + "UsersDeleteAdminUserMutateDeleteOperationError", + "UsersGet", + "UsersGetAdmin", + "UsersGetAdminUser", + "UsersGetAdminUserGet", + "UsersList", + "UsersListAdmin", + "UsersListAdminUser", + "UsersListAdminUserList", + "UsersRemoveRoles", + "UsersRemoveRolesAdmin", + "UsersRemoveRolesAdminUser", + "UsersRemoveRolesAdminUserMutate", + "UsersRemoveRolesAdminUserMutateRemoveRolesInvalidInput", + "UsersRemoveRolesAdminUserMutateRemoveRolesOperationError", + "UsersRemoveRolesAdminUserMutateRemoveRolesUser", + "UsersResetPassword", + "UsersResetPasswordAdmin", + "UsersResetPasswordAdminUser", + "UsersResetPasswordAdminUserMutate", + "UsersResetPasswordAdminUserMutateResetPasswordInvalidInput", + "UsersResetPasswordAdminUserMutateResetPasswordOk", + "UsersResetPasswordAdminUserMutateResetPasswordOperationError", + "UsersSetActive", + "UsersSetActiveAdmin", + "UsersSetActiveAdminUser", + "UsersSetActiveAdminUserMutate", + "UsersSetActiveAdminUserMutateSetActiveInvalidInput", + "UsersSetActiveAdminUserMutateSetActiveOperationError", + "UsersSetActiveAdminUserMutateSetActiveUser", + "UsersSetAdmin", + "UsersSetAdminAdmin", + "UsersSetAdminAdminUser", + "UsersSetAdminAdminUserMutate", + "UsersSetAdminAdminUserMutateSetAdminInvalidInput", + "UsersSetAdminAdminUserMutateSetAdminOperationError", + "UsersSetAdminAdminUserMutateSetAdminUser", + "UsersSetEmail", + "UsersSetEmailAdmin", + "UsersSetEmailAdminUser", + "UsersSetEmailAdminUserMutate", + "UsersSetEmailAdminUserMutateSetEmailInvalidInput", + "UsersSetEmailAdminUserMutateSetEmailOperationError", + "UsersSetEmailAdminUserMutateSetEmailUser", + "UsersSetRole", + "UsersSetRoleAdmin", + "UsersSetRoleAdminUser", + "UsersSetRoleAdminUserMutate", + "UsersSetRoleAdminUserMutateSetRoleInvalidInput", + "UsersSetRoleAdminUserMutateSetRoleOperationError", + "UsersSetRoleAdminUserMutateSetRoleUser", +] diff --git a/api/python/quilt3/admin/_graphql_client/base_client.py b/api/python/quilt3/admin/_graphql_client/base_client.py new file mode 100644 index 00000000000..298d14e5a8a --- /dev/null +++ b/api/python/quilt3/admin/_graphql_client/base_client.py @@ -0,0 +1,213 @@ +# Generated by ariadne-codegen + +# This is +# https://github.com/mirumee/ariadne-codegen/blob/5bfd63c5e7e3a8cc5293eb94deee638b7adab98d/ariadne_codegen/client_generators/dependencies/base_client.py +# modified to use our requests session instead of httpx. +# pylint: disable=relative-beyond-top-level +import json +from typing import IO, Any, Dict, List, Optional, Tuple, TypeVar, cast + +import requests +from pydantic import BaseModel +from pydantic_core import to_jsonable_python + +from quilt3 import session + +from .base_model import UNSET, Upload +from .exceptions import ( + GraphQLClientGraphQLMultiError, + GraphQLClientHttpError, + GraphQLClientInvalidResponseError, +) + +Self = TypeVar("Self", bound="BaseClient") + + +class BaseClient: + def __init__( + self, + ) -> None: + self.url = session.get_registry_url() + "/graphql" + + self.http_client = session.get_session() + + def __enter__(self: Self) -> Self: + return self + + def __exit__( + self, + exc_type: object, + exc_val: object, + exc_tb: object, + ) -> None: + self.http_client.close() + + def execute( + self, + query: str, + operation_name: Optional[str] = None, + variables: Optional[Dict[str, Any]] = None, + **kwargs: Any, + ) -> requests.Response: + processed_variables, files, files_map = self._process_variables(variables) + + if files and files_map: + return self._execute_multipart( + query=query, + operation_name=operation_name, + variables=processed_variables, + files=files, + files_map=files_map, + **kwargs, + ) + + return self._execute_json( + query=query, + operation_name=operation_name, + variables=processed_variables, + **kwargs, + ) + + def get_data(self, response: requests.Response) -> Dict[str, Any]: + if not 200 <= response.status_code < 300: + raise GraphQLClientHttpError( + status_code=response.status_code, response=response + ) + + try: + response_json = response.json() + except ValueError as exc: + raise GraphQLClientInvalidResponseError(response=response) from exc + + if (not isinstance(response_json, dict)) or ( + "data" not in response_json and "errors" not in response_json + ): + raise GraphQLClientInvalidResponseError(response=response) + + data = response_json.get("data") + errors = response_json.get("errors") + + if errors: + raise GraphQLClientGraphQLMultiError.from_errors_dicts( + errors_dicts=errors, data=data + ) + + return cast(Dict[str, Any], data) + + def _process_variables( + self, variables: Optional[Dict[str, Any]] + ) -> Tuple[ + Dict[str, Any], Dict[str, Tuple[str, IO[bytes], str]], Dict[str, List[str]] + ]: + if not variables: + return {}, {}, {} + + serializable_variables = self._convert_dict_to_json_serializable(variables) + return self._get_files_from_variables(serializable_variables) + + def _convert_dict_to_json_serializable( + self, dict_: Dict[str, Any] + ) -> Dict[str, Any]: + return { + key: self._convert_value(value) + for key, value in dict_.items() + if value is not UNSET + } + + def _convert_value(self, value: Any) -> Any: + if isinstance(value, BaseModel): + return value.model_dump(by_alias=True, exclude_unset=True) + if isinstance(value, list): + return [self._convert_value(item) for item in value] + return value + + def _get_files_from_variables( + self, variables: Dict[str, Any] + ) -> Tuple[ + Dict[str, Any], Dict[str, Tuple[str, IO[bytes], str]], Dict[str, List[str]] + ]: + files_map: Dict[str, List[str]] = {} + files_list: List[Upload] = [] + + def separate_files(path: str, obj: Any) -> Any: + if isinstance(obj, list): + nulled_list = [] + for index, value in enumerate(obj): + value = separate_files(f"{path}.{index}", value) + nulled_list.append(value) + return nulled_list + + if isinstance(obj, dict): + nulled_dict = {} + for key, value in obj.items(): + value = separate_files(f"{path}.{key}", value) + nulled_dict[key] = value + return nulled_dict + + if isinstance(obj, Upload): + if obj in files_list: + file_index = files_list.index(obj) + files_map[str(file_index)].append(path) + else: + file_index = len(files_list) + files_list.append(obj) + files_map[str(file_index)] = [path] + return None + + return obj + + nulled_variables = separate_files("variables", variables) + files: Dict[str, Tuple[str, IO[bytes], str]] = { + str(i): (file_.filename, cast(IO[bytes], file_.content), file_.content_type) + for i, file_ in enumerate(files_list) + } + return nulled_variables, files, files_map + + def _execute_multipart( + self, + query: str, + operation_name: Optional[str], + variables: Dict[str, Any], + files: Dict[str, Tuple[str, IO[bytes], str]], + files_map: Dict[str, List[str]], + **kwargs: Any, + ) -> requests.Response: + data = { + "operations": json.dumps( + { + "query": query, + "operationName": operation_name, + "variables": variables, + }, + default=to_jsonable_python, + ), + "map": json.dumps(files_map, default=to_jsonable_python), + } + + return self.http_client.post(url=self.url, data=data, files=files, **kwargs) + + def _execute_json( + self, + query: str, + operation_name: Optional[str], + variables: Dict[str, Any], + **kwargs: Any, + ) -> requests.Response: + headers: Dict[str, str] = {"Content-Type": "application/json"} + headers.update(kwargs.get("headers", {})) + + merged_kwargs: Dict[str, Any] = kwargs.copy() + merged_kwargs["headers"] = headers + + return self.http_client.post( + url=self.url, + data=json.dumps( + { + "query": query, + "operationName": operation_name, + "variables": variables, + }, + default=to_jsonable_python, + ), + **merged_kwargs, + ) diff --git a/api/python/quilt3/admin/_graphql_client/base_model.py b/api/python/quilt3/admin/_graphql_client/base_model.py new file mode 100644 index 00000000000..76b84873a6f --- /dev/null +++ b/api/python/quilt3/admin/_graphql_client/base_model.py @@ -0,0 +1,29 @@ +# Generated by ariadne-codegen + +from io import IOBase + +from pydantic import BaseModel as PydanticBaseModel, ConfigDict + + +class UnsetType: + def __bool__(self) -> bool: + return False + + +UNSET = UnsetType() + + +class BaseModel(PydanticBaseModel): + model_config = ConfigDict( + populate_by_name=True, + validate_assignment=True, + arbitrary_types_allowed=True, + protected_namespaces=(), + ) + + +class Upload: + def __init__(self, filename: str, content: IOBase, content_type: str): + self.filename = filename + self.content = content + self.content_type = content_type diff --git a/api/python/quilt3/admin/_graphql_client/client.py b/api/python/quilt3/admin/_graphql_client/client.py new file mode 100644 index 00000000000..12686f8ff48 --- /dev/null +++ b/api/python/quilt3/admin/_graphql_client/client.py @@ -0,0 +1,854 @@ +# Generated by ariadne-codegen +# Source: queries.graphql + +from typing import Any, Dict, List, Optional, Union + +from .base_client import BaseClient +from .base_model import UNSET, UnsetType +from .input_types import UserInput +from .roles_list import ( + RolesList, + RolesListRolesManagedRole, + RolesListRolesUnmanagedRole, +) +from .users_add_roles import UsersAddRoles, UsersAddRolesAdminUserMutate +from .users_create import ( + UsersCreate, + UsersCreateAdminUserCreateInvalidInput, + UsersCreateAdminUserCreateOperationError, + UsersCreateAdminUserCreateUser, +) +from .users_delete import UsersDelete, UsersDeleteAdminUserMutate +from .users_get import UsersGet, UsersGetAdminUserGet +from .users_list import UsersList, UsersListAdminUserList +from .users_remove_roles import UsersRemoveRoles, UsersRemoveRolesAdminUserMutate +from .users_reset_password import UsersResetPassword, UsersResetPasswordAdminUserMutate +from .users_set_active import UsersSetActive, UsersSetActiveAdminUserMutate +from .users_set_admin import UsersSetAdmin, UsersSetAdminAdminUserMutate +from .users_set_email import UsersSetEmail, UsersSetEmailAdminUserMutate +from .users_set_role import UsersSetRole, UsersSetRoleAdminUserMutate + + +def gql(q: str) -> str: + return q + + +class Client(BaseClient): + def roles_list( + self, **kwargs: Any + ) -> List[Union[RolesListRolesUnmanagedRole, RolesListRolesManagedRole]]: + query = gql( + """ + query rolesList { + roles { + ...RoleSelection + } + } + + fragment ManagedRoleSelection on ManagedRole { + id + name + arn + } + + fragment RoleSelection on Role { + __typename + ...UnmanagedRoleSelection + ...ManagedRoleSelection + } + + fragment UnmanagedRoleSelection on UnmanagedRole { + id + name + arn + } + """ + ) + variables: Dict[str, object] = {} + response = self.execute( + query=query, operation_name="rolesList", variables=variables, **kwargs + ) + data = self.get_data(response) + return RolesList.model_validate(data).roles + + def users_get(self, name: str, **kwargs: Any) -> Optional[UsersGetAdminUserGet]: + query = gql( + """ + query usersGet($name: String!) { + admin { + user { + get(name: $name) { + ...UserSelection + } + } + } + } + + fragment ManagedRoleSelection on ManagedRole { + id + name + arn + } + + fragment RoleSelection on Role { + __typename + ...UnmanagedRoleSelection + ...ManagedRoleSelection + } + + fragment UnmanagedRoleSelection on UnmanagedRole { + id + name + arn + } + + fragment UserSelection on User { + name + email + dateJoined + lastLogin + isActive + isAdmin + isSsoOnly + isService + role { + ...RoleSelection + } + extraRoles { + ...RoleSelection + } + } + """ + ) + variables: Dict[str, object] = {"name": name} + response = self.execute( + query=query, operation_name="usersGet", variables=variables, **kwargs + ) + data = self.get_data(response) + return UsersGet.model_validate(data).admin.user.get + + def users_list(self, **kwargs: Any) -> List[UsersListAdminUserList]: + query = gql( + """ + query usersList { + admin { + user { + list { + ...UserSelection + } + } + } + } + + fragment ManagedRoleSelection on ManagedRole { + id + name + arn + } + + fragment RoleSelection on Role { + __typename + ...UnmanagedRoleSelection + ...ManagedRoleSelection + } + + fragment UnmanagedRoleSelection on UnmanagedRole { + id + name + arn + } + + fragment UserSelection on User { + name + email + dateJoined + lastLogin + isActive + isAdmin + isSsoOnly + isService + role { + ...RoleSelection + } + extraRoles { + ...RoleSelection + } + } + """ + ) + variables: Dict[str, object] = {} + response = self.execute( + query=query, operation_name="usersList", variables=variables, **kwargs + ) + data = self.get_data(response) + return UsersList.model_validate(data).admin.user.list + + def users_create(self, input: UserInput, **kwargs: Any) -> Union[ + UsersCreateAdminUserCreateUser, + UsersCreateAdminUserCreateInvalidInput, + UsersCreateAdminUserCreateOperationError, + ]: + query = gql( + """ + mutation usersCreate($input: UserInput!) { + admin { + user { + create(input: $input) { + __typename + ...UserMutationSelection + } + } + } + } + + fragment InvalidInputSelection on InvalidInput { + errors { + path + message + name + context + } + } + + fragment ManagedRoleSelection on ManagedRole { + id + name + arn + } + + fragment OperationErrorSelection on OperationError { + message + name + context + } + + fragment RoleSelection on Role { + __typename + ...UnmanagedRoleSelection + ...ManagedRoleSelection + } + + fragment UnmanagedRoleSelection on UnmanagedRole { + id + name + arn + } + + fragment UserMutationSelection on UserResult { + ...UserSelection + ...InvalidInputSelection + ...OperationErrorSelection + } + + fragment UserSelection on User { + name + email + dateJoined + lastLogin + isActive + isAdmin + isSsoOnly + isService + role { + ...RoleSelection + } + extraRoles { + ...RoleSelection + } + } + """ + ) + variables: Dict[str, object] = {"input": input} + response = self.execute( + query=query, operation_name="usersCreate", variables=variables, **kwargs + ) + data = self.get_data(response) + return UsersCreate.model_validate(data).admin.user.create + + def users_delete( + self, name: str, **kwargs: Any + ) -> Optional[UsersDeleteAdminUserMutate]: + query = gql( + """ + mutation usersDelete($name: String!) { + admin { + user { + mutate(name: $name) { + delete { + __typename + ...InvalidInputSelection + ...OperationErrorSelection + } + } + } + } + } + + fragment InvalidInputSelection on InvalidInput { + errors { + path + message + name + context + } + } + + fragment OperationErrorSelection on OperationError { + message + name + context + } + """ + ) + variables: Dict[str, object] = {"name": name} + response = self.execute( + query=query, operation_name="usersDelete", variables=variables, **kwargs + ) + data = self.get_data(response) + return UsersDelete.model_validate(data).admin.user.mutate + + def users_set_email( + self, email: str, name: str, **kwargs: Any + ) -> Optional[UsersSetEmailAdminUserMutate]: + query = gql( + """ + mutation usersSetEmail($email: String!, $name: String!) { + admin { + user { + mutate(name: $name) { + setEmail(email: $email) { + __typename + ...UserMutationSelection + } + } + } + } + } + + fragment InvalidInputSelection on InvalidInput { + errors { + path + message + name + context + } + } + + fragment ManagedRoleSelection on ManagedRole { + id + name + arn + } + + fragment OperationErrorSelection on OperationError { + message + name + context + } + + fragment RoleSelection on Role { + __typename + ...UnmanagedRoleSelection + ...ManagedRoleSelection + } + + fragment UnmanagedRoleSelection on UnmanagedRole { + id + name + arn + } + + fragment UserMutationSelection on UserResult { + ...UserSelection + ...InvalidInputSelection + ...OperationErrorSelection + } + + fragment UserSelection on User { + name + email + dateJoined + lastLogin + isActive + isAdmin + isSsoOnly + isService + role { + ...RoleSelection + } + extraRoles { + ...RoleSelection + } + } + """ + ) + variables: Dict[str, object] = {"email": email, "name": name} + response = self.execute( + query=query, operation_name="usersSetEmail", variables=variables, **kwargs + ) + data = self.get_data(response) + return UsersSetEmail.model_validate(data).admin.user.mutate + + def users_set_admin( + self, name: str, admin: bool, **kwargs: Any + ) -> Optional[UsersSetAdminAdminUserMutate]: + query = gql( + """ + mutation usersSetAdmin($name: String!, $admin: Boolean!) { + admin { + user { + mutate(name: $name) { + setAdmin(admin: $admin) { + __typename + ...UserMutationSelection + } + } + } + } + } + + fragment InvalidInputSelection on InvalidInput { + errors { + path + message + name + context + } + } + + fragment ManagedRoleSelection on ManagedRole { + id + name + arn + } + + fragment OperationErrorSelection on OperationError { + message + name + context + } + + fragment RoleSelection on Role { + __typename + ...UnmanagedRoleSelection + ...ManagedRoleSelection + } + + fragment UnmanagedRoleSelection on UnmanagedRole { + id + name + arn + } + + fragment UserMutationSelection on UserResult { + ...UserSelection + ...InvalidInputSelection + ...OperationErrorSelection + } + + fragment UserSelection on User { + name + email + dateJoined + lastLogin + isActive + isAdmin + isSsoOnly + isService + role { + ...RoleSelection + } + extraRoles { + ...RoleSelection + } + } + """ + ) + variables: Dict[str, object] = {"name": name, "admin": admin} + response = self.execute( + query=query, operation_name="usersSetAdmin", variables=variables, **kwargs + ) + data = self.get_data(response) + return UsersSetAdmin.model_validate(data).admin.user.mutate + + def users_set_active( + self, active: bool, name: str, **kwargs: Any + ) -> Optional[UsersSetActiveAdminUserMutate]: + query = gql( + """ + mutation usersSetActive($active: Boolean!, $name: String!) { + admin { + user { + mutate(name: $name) { + setActive(active: $active) { + __typename + ...UserMutationSelection + } + } + } + } + } + + fragment InvalidInputSelection on InvalidInput { + errors { + path + message + name + context + } + } + + fragment ManagedRoleSelection on ManagedRole { + id + name + arn + } + + fragment OperationErrorSelection on OperationError { + message + name + context + } + + fragment RoleSelection on Role { + __typename + ...UnmanagedRoleSelection + ...ManagedRoleSelection + } + + fragment UnmanagedRoleSelection on UnmanagedRole { + id + name + arn + } + + fragment UserMutationSelection on UserResult { + ...UserSelection + ...InvalidInputSelection + ...OperationErrorSelection + } + + fragment UserSelection on User { + name + email + dateJoined + lastLogin + isActive + isAdmin + isSsoOnly + isService + role { + ...RoleSelection + } + extraRoles { + ...RoleSelection + } + } + """ + ) + variables: Dict[str, object] = {"active": active, "name": name} + response = self.execute( + query=query, operation_name="usersSetActive", variables=variables, **kwargs + ) + data = self.get_data(response) + return UsersSetActive.model_validate(data).admin.user.mutate + + def users_reset_password( + self, name: str, **kwargs: Any + ) -> Optional[UsersResetPasswordAdminUserMutate]: + query = gql( + """ + mutation usersResetPassword($name: String!) { + admin { + user { + mutate(name: $name) { + resetPassword { + __typename + ...InvalidInputSelection + ...OperationErrorSelection + } + } + } + } + } + + fragment InvalidInputSelection on InvalidInput { + errors { + path + message + name + context + } + } + + fragment OperationErrorSelection on OperationError { + message + name + context + } + """ + ) + variables: Dict[str, object] = {"name": name} + response = self.execute( + query=query, + operation_name="usersResetPassword", + variables=variables, + **kwargs + ) + data = self.get_data(response) + return UsersResetPassword.model_validate(data).admin.user.mutate + + def users_set_role( + self, + name: str, + role: str, + append: bool, + extra_roles: Union[Optional[List[str]], UnsetType] = UNSET, + **kwargs: Any + ) -> Optional[UsersSetRoleAdminUserMutate]: + query = gql( + """ + mutation usersSetRole($name: String!, $role: String!, $extraRoles: [String!], $append: Boolean!) { + admin { + user { + mutate(name: $name) { + setRole(role: $role, extraRoles: $extraRoles, append: $append) { + __typename + ...UserSelection + ...InvalidInputSelection + ...OperationErrorSelection + } + } + } + } + } + + fragment InvalidInputSelection on InvalidInput { + errors { + path + message + name + context + } + } + + fragment ManagedRoleSelection on ManagedRole { + id + name + arn + } + + fragment OperationErrorSelection on OperationError { + message + name + context + } + + fragment RoleSelection on Role { + __typename + ...UnmanagedRoleSelection + ...ManagedRoleSelection + } + + fragment UnmanagedRoleSelection on UnmanagedRole { + id + name + arn + } + + fragment UserSelection on User { + name + email + dateJoined + lastLogin + isActive + isAdmin + isSsoOnly + isService + role { + ...RoleSelection + } + extraRoles { + ...RoleSelection + } + } + """ + ) + variables: Dict[str, object] = { + "name": name, + "role": role, + "extraRoles": extra_roles, + "append": append, + } + response = self.execute( + query=query, operation_name="usersSetRole", variables=variables, **kwargs + ) + data = self.get_data(response) + return UsersSetRole.model_validate(data).admin.user.mutate + + def users_add_roles( + self, name: str, roles: List[str], **kwargs: Any + ) -> Optional[UsersAddRolesAdminUserMutate]: + query = gql( + """ + mutation usersAddRoles($name: String!, $roles: [String!]!) { + admin { + user { + mutate(name: $name) { + addRoles(roles: $roles) { + __typename + ...UserSelection + ...InvalidInputSelection + ...OperationErrorSelection + } + } + } + } + } + + fragment InvalidInputSelection on InvalidInput { + errors { + path + message + name + context + } + } + + fragment ManagedRoleSelection on ManagedRole { + id + name + arn + } + + fragment OperationErrorSelection on OperationError { + message + name + context + } + + fragment RoleSelection on Role { + __typename + ...UnmanagedRoleSelection + ...ManagedRoleSelection + } + + fragment UnmanagedRoleSelection on UnmanagedRole { + id + name + arn + } + + fragment UserSelection on User { + name + email + dateJoined + lastLogin + isActive + isAdmin + isSsoOnly + isService + role { + ...RoleSelection + } + extraRoles { + ...RoleSelection + } + } + """ + ) + variables: Dict[str, object] = {"name": name, "roles": roles} + response = self.execute( + query=query, operation_name="usersAddRoles", variables=variables, **kwargs + ) + data = self.get_data(response) + return UsersAddRoles.model_validate(data).admin.user.mutate + + def users_remove_roles( + self, + name: str, + roles: List[str], + fallback: Union[Optional[str], UnsetType] = UNSET, + **kwargs: Any + ) -> Optional[UsersRemoveRolesAdminUserMutate]: + query = gql( + """ + mutation usersRemoveRoles($name: String!, $roles: [String!]!, $fallback: String) { + admin { + user { + mutate(name: $name) { + removeRoles(roles: $roles, fallback: $fallback) { + __typename + ...UserSelection + ...InvalidInputSelection + ...OperationErrorSelection + } + } + } + } + } + + fragment InvalidInputSelection on InvalidInput { + errors { + path + message + name + context + } + } + + fragment ManagedRoleSelection on ManagedRole { + id + name + arn + } + + fragment OperationErrorSelection on OperationError { + message + name + context + } + + fragment RoleSelection on Role { + __typename + ...UnmanagedRoleSelection + ...ManagedRoleSelection + } + + fragment UnmanagedRoleSelection on UnmanagedRole { + id + name + arn + } + + fragment UserSelection on User { + name + email + dateJoined + lastLogin + isActive + isAdmin + isSsoOnly + isService + role { + ...RoleSelection + } + extraRoles { + ...RoleSelection + } + } + """ + ) + variables: Dict[str, object] = { + "name": name, + "roles": roles, + "fallback": fallback, + } + response = self.execute( + query=query, + operation_name="usersRemoveRoles", + variables=variables, + **kwargs + ) + data = self.get_data(response) + return UsersRemoveRoles.model_validate(data).admin.user.mutate diff --git a/api/python/quilt3/admin/_graphql_client/enums.py b/api/python/quilt3/admin/_graphql_client/enums.py new file mode 100644 index 00000000000..638363665ea --- /dev/null +++ b/api/python/quilt3/admin/_graphql_client/enums.py @@ -0,0 +1,3 @@ +# Generated by ariadne-codegen +# Source: ../../../shared/graphql/schema.graphql + diff --git a/api/python/quilt3/admin/_graphql_client/exceptions.py b/api/python/quilt3/admin/_graphql_client/exceptions.py new file mode 100644 index 00000000000..f42118680cc --- /dev/null +++ b/api/python/quilt3/admin/_graphql_client/exceptions.py @@ -0,0 +1,89 @@ +# Generated by ariadne-codegen + +# This is +# https://github.com/mirumee/ariadne-codegen/blob/5bfd63c5e7e3a8cc5293eb94deee638b7adab98d/ariadne_codegen/client_generators/dependencies/exceptions.py +# modified to use our requests instead of httpx. +# pylint: disable=super-init-not-called +from typing import Any, Dict, List, Optional, Union + +import requests + + +class GraphQLClientError(Exception): + """Base exception.""" + + +class GraphQLClientHttpError(GraphQLClientError): + def __init__(self, status_code: int, response: requests.Response) -> None: + self.status_code = status_code + self.response = response + + def __str__(self) -> str: + return f"HTTP status code: {self.status_code}" + + +class GraphQLClientInvalidResponseError(GraphQLClientError): + def __init__(self, response: requests.Response) -> None: + self.response = response + + def __str__(self) -> str: + return "Invalid response format." + + +class GraphQLClientGraphQLError(GraphQLClientError): + def __init__( + self, + message: str, + locations: Optional[List[Dict[str, int]]] = None, + path: Optional[List[str]] = None, + extensions: Optional[Dict[str, object]] = None, + orginal: Optional[Dict[str, object]] = None, + ): + self.message = message + self.locations = locations + self.path = path + self.extensions = extensions + self.orginal = orginal + + def __str__(self) -> str: + return self.message + + @classmethod + def from_dict(cls, error: Dict[str, Any]) -> "GraphQLClientGraphQLError": + return cls( + message=error["message"], + locations=error.get("locations"), + path=error.get("path"), + extensions=error.get("extensions"), + orginal=error, + ) + + +class GraphQLClientGraphQLMultiError(GraphQLClientError): + def __init__( + self, + errors: List[GraphQLClientGraphQLError], + data: Optional[Dict[str, Any]] = None, + ): + self.errors = errors + self.data = data + + def __str__(self) -> str: + return "; ".join(str(e) for e in self.errors) + + @classmethod + def from_errors_dicts( + cls, errors_dicts: List[Dict[str, Any]], data: Optional[Dict[str, Any]] = None + ) -> "GraphQLClientGraphQLMultiError": + return cls( + errors=[GraphQLClientGraphQLError.from_dict(e) for e in errors_dicts], + data=data, + ) + + +class GraphQLClientInvalidMessageFormat(GraphQLClientError): + def __init__(self, message: Union[str, bytes]) -> None: + self.message = message + + def __str__(self) -> str: + return "Invalid message format." diff --git a/api/python/quilt3/admin/_graphql_client/fragments.py b/api/python/quilt3/admin/_graphql_client/fragments.py new file mode 100644 index 00000000000..33cd794855c --- /dev/null +++ b/api/python/quilt3/admin/_graphql_client/fragments.py @@ -0,0 +1,87 @@ +# Generated by ariadne-codegen +# Source: queries.graphql + +from datetime import datetime +from typing import Annotated, Any, List, Literal, Optional, Union + +from pydantic import Field + +from .base_model import BaseModel + + +class InvalidInputSelection(BaseModel): + errors: List["InvalidInputSelectionErrors"] + + +class InvalidInputSelectionErrors(BaseModel): + path: Optional[str] + message: str + name: str + context: Optional[Any] + + +class ManagedRoleSelection(BaseModel): + id: str + name: str + arn: str + + +class OperationErrorSelection(BaseModel): + message: str + name: str + context: Optional[Any] + + +class UnmanagedRoleSelection(BaseModel): + id: str + name: str + arn: str + + +class UserSelection(BaseModel): + name: str + email: str + date_joined: datetime = Field(alias="dateJoined") + last_login: datetime = Field(alias="lastLogin") + is_active: bool = Field(alias="isActive") + is_admin: bool = Field(alias="isAdmin") + is_sso_only: bool = Field(alias="isSsoOnly") + is_service: bool = Field(alias="isService") + role: Optional[ + Annotated[ + Union["UserSelectionRoleUnmanagedRole", "UserSelectionRoleManagedRole"], + Field(discriminator="typename__"), + ] + ] + extra_roles: List[ + Annotated[ + Union[ + "UserSelectionExtraRolesUnmanagedRole", + "UserSelectionExtraRolesManagedRole", + ], + Field(discriminator="typename__"), + ] + ] = Field(alias="extraRoles") + + +class UserSelectionRoleUnmanagedRole(UnmanagedRoleSelection): + typename__: Literal["UnmanagedRole"] = Field(alias="__typename") + + +class UserSelectionRoleManagedRole(ManagedRoleSelection): + typename__: Literal["ManagedRole"] = Field(alias="__typename") + + +class UserSelectionExtraRolesUnmanagedRole(UnmanagedRoleSelection): + typename__: Literal["UnmanagedRole"] = Field(alias="__typename") + + +class UserSelectionExtraRolesManagedRole(ManagedRoleSelection): + typename__: Literal["ManagedRole"] = Field(alias="__typename") + + +InvalidInputSelection.model_rebuild() +ManagedRoleSelection.model_rebuild() +OperationErrorSelection.model_rebuild() +UnmanagedRoleSelection.model_rebuild() +UserSelection.model_rebuild() diff --git a/api/python/quilt3/admin/_graphql_client/input_types.py b/api/python/quilt3/admin/_graphql_client/input_types.py new file mode 100644 index 00000000000..cb87f01d15a --- /dev/null +++ b/api/python/quilt3/admin/_graphql_client/input_types.py @@ -0,0 +1,16 @@ +# Generated by ariadne-codegen +# Source: ../../../shared/graphql/schema.graphql + +from datetime import datetime +from typing import Annotated, Any, List, Optional, Union + +from pydantic import Field, PlainSerializer + +from .base_model import BaseModel, Upload + + +class UserInput(BaseModel): + name: str + email: str + role: str + extra_roles: Optional[List[str]] = Field(alias="extraRoles", default=None) diff --git a/api/python/quilt3/admin/_graphql_client/roles_list.py b/api/python/quilt3/admin/_graphql_client/roles_list.py new file mode 100644 index 00000000000..37598459172 --- /dev/null +++ b/api/python/quilt3/admin/_graphql_client/roles_list.py @@ -0,0 +1,29 @@ +# Generated by ariadne-codegen +# Source: queries.graphql + +from typing import Annotated, List, Literal, Union + +from pydantic import Field + +from .base_model import BaseModel +from .fragments import ManagedRoleSelection, UnmanagedRoleSelection + + +class RolesList(BaseModel): + roles: List[ + Annotated[ + Union["RolesListRolesUnmanagedRole", "RolesListRolesManagedRole"], + Field(discriminator="typename__"), + ] + ] + + +class RolesListRolesUnmanagedRole(UnmanagedRoleSelection): + typename__: Literal["UnmanagedRole"] = Field(alias="__typename") + + +class RolesListRolesManagedRole(ManagedRoleSelection): + typename__: Literal["ManagedRole"] = Field(alias="__typename") + + +RolesList.model_rebuild() diff --git a/api/python/quilt3/admin/_graphql_client/users_add_roles.py b/api/python/quilt3/admin/_graphql_client/users_add_roles.py new file mode 100644 index 00000000000..101a2f8de2d --- /dev/null +++ b/api/python/quilt3/admin/_graphql_client/users_add_roles.py @@ -0,0 +1,47 @@ +# Generated by ariadne-codegen +# Source: queries.graphql + +from typing import Literal, Optional, Union + +from pydantic import Field + +from .base_model import BaseModel +from .fragments import InvalidInputSelection, OperationErrorSelection, UserSelection + + +class UsersAddRoles(BaseModel): + admin: "UsersAddRolesAdmin" + + +class UsersAddRolesAdmin(BaseModel): + user: "UsersAddRolesAdminUser" + + +class UsersAddRolesAdminUser(BaseModel): + mutate: Optional["UsersAddRolesAdminUserMutate"] + + +class UsersAddRolesAdminUserMutate(BaseModel): + add_roles: Union[ + "UsersAddRolesAdminUserMutateAddRolesUser", + "UsersAddRolesAdminUserMutateAddRolesInvalidInput", + "UsersAddRolesAdminUserMutateAddRolesOperationError", + ] = Field(alias="addRoles", discriminator="typename__") + + +class UsersAddRolesAdminUserMutateAddRolesUser(UserSelection): + typename__: Literal["User"] = Field(alias="__typename") + + +class UsersAddRolesAdminUserMutateAddRolesInvalidInput(InvalidInputSelection): + typename__: Literal["InvalidInput"] = Field(alias="__typename") + + +class UsersAddRolesAdminUserMutateAddRolesOperationError(OperationErrorSelection): + typename__: Literal["OperationError"] = Field(alias="__typename") + + +UsersAddRoles.model_rebuild() +UsersAddRolesAdmin.model_rebuild() +UsersAddRolesAdminUser.model_rebuild() +UsersAddRolesAdminUserMutate.model_rebuild() diff --git a/api/python/quilt3/admin/_graphql_client/users_create.py b/api/python/quilt3/admin/_graphql_client/users_create.py new file mode 100644 index 00000000000..2b696f4dcd3 --- /dev/null +++ b/api/python/quilt3/admin/_graphql_client/users_create.py @@ -0,0 +1,42 @@ +# Generated by ariadne-codegen +# Source: queries.graphql + +from typing import Literal, Union + +from pydantic import Field + +from .base_model import BaseModel +from .fragments import InvalidInputSelection, OperationErrorSelection, UserSelection + + +class UsersCreate(BaseModel): + admin: "UsersCreateAdmin" + + +class UsersCreateAdmin(BaseModel): + user: "UsersCreateAdminUser" + + +class UsersCreateAdminUser(BaseModel): + create: Union[ + "UsersCreateAdminUserCreateUser", + "UsersCreateAdminUserCreateInvalidInput", + "UsersCreateAdminUserCreateOperationError", + ] = Field(discriminator="typename__") + + +class UsersCreateAdminUserCreateUser(UserSelection): + typename__: Literal["User"] = Field(alias="__typename") + + +class UsersCreateAdminUserCreateInvalidInput(InvalidInputSelection): + typename__: Literal["InvalidInput"] = Field(alias="__typename") + + +class UsersCreateAdminUserCreateOperationError(OperationErrorSelection): + typename__: Literal["OperationError"] = Field(alias="__typename") + + +UsersCreate.model_rebuild() +UsersCreateAdmin.model_rebuild() +UsersCreateAdminUser.model_rebuild() diff --git a/api/python/quilt3/admin/_graphql_client/users_delete.py b/api/python/quilt3/admin/_graphql_client/users_delete.py new file mode 100644 index 00000000000..3f5cdc726a2 --- /dev/null +++ b/api/python/quilt3/admin/_graphql_client/users_delete.py @@ -0,0 +1,47 @@ +# Generated by ariadne-codegen +# Source: queries.graphql + +from typing import Literal, Optional, Union + +from pydantic import Field + +from .base_model import BaseModel +from .fragments import InvalidInputSelection, OperationErrorSelection + + +class UsersDelete(BaseModel): + admin: "UsersDeleteAdmin" + + +class UsersDeleteAdmin(BaseModel): + user: "UsersDeleteAdminUser" + + +class UsersDeleteAdminUser(BaseModel): + mutate: Optional["UsersDeleteAdminUserMutate"] + + +class UsersDeleteAdminUserMutate(BaseModel): + delete: Union[ + "UsersDeleteAdminUserMutateDeleteOk", + "UsersDeleteAdminUserMutateDeleteInvalidInput", + "UsersDeleteAdminUserMutateDeleteOperationError", + ] = Field(discriminator="typename__") + + +class UsersDeleteAdminUserMutateDeleteOk(BaseModel): + typename__: Literal["Ok"] = Field(alias="__typename") + + +class UsersDeleteAdminUserMutateDeleteInvalidInput(InvalidInputSelection): + typename__: Literal["InvalidInput"] = Field(alias="__typename") + + +class UsersDeleteAdminUserMutateDeleteOperationError(OperationErrorSelection): + typename__: Literal["OperationError"] = Field(alias="__typename") + + +UsersDelete.model_rebuild() +UsersDeleteAdmin.model_rebuild() +UsersDeleteAdminUser.model_rebuild() +UsersDeleteAdminUserMutate.model_rebuild() diff --git a/api/python/quilt3/admin/_graphql_client/users_get.py b/api/python/quilt3/admin/_graphql_client/users_get.py new file mode 100644 index 00000000000..3a93f98aa2d --- /dev/null +++ b/api/python/quilt3/admin/_graphql_client/users_get.py @@ -0,0 +1,28 @@ +# Generated by ariadne-codegen +# Source: queries.graphql + +from typing import Optional + +from .base_model import BaseModel +from .fragments import UserSelection + + +class UsersGet(BaseModel): + admin: "UsersGetAdmin" + + +class UsersGetAdmin(BaseModel): + user: "UsersGetAdminUser" + + +class UsersGetAdminUser(BaseModel): + get: Optional["UsersGetAdminUserGet"] + + +class UsersGetAdminUserGet(UserSelection): + pass + + +UsersGet.model_rebuild() +UsersGetAdmin.model_rebuild() +UsersGetAdminUser.model_rebuild() diff --git a/api/python/quilt3/admin/_graphql_client/users_list.py b/api/python/quilt3/admin/_graphql_client/users_list.py new file mode 100644 index 00000000000..bd85e2399cf --- /dev/null +++ b/api/python/quilt3/admin/_graphql_client/users_list.py @@ -0,0 +1,28 @@ +# Generated by ariadne-codegen +# Source: queries.graphql + +from typing import List + +from .base_model import BaseModel +from .fragments import UserSelection + + +class UsersList(BaseModel): + admin: "UsersListAdmin" + + +class UsersListAdmin(BaseModel): + user: "UsersListAdminUser" + + +class UsersListAdminUser(BaseModel): + list: List["UsersListAdminUserList"] + + +class UsersListAdminUserList(UserSelection): + pass + + +UsersList.model_rebuild() +UsersListAdmin.model_rebuild() +UsersListAdminUser.model_rebuild() diff --git a/api/python/quilt3/admin/_graphql_client/users_remove_roles.py b/api/python/quilt3/admin/_graphql_client/users_remove_roles.py new file mode 100644 index 00000000000..521dda0feef --- /dev/null +++ b/api/python/quilt3/admin/_graphql_client/users_remove_roles.py @@ -0,0 +1,47 @@ +# Generated by ariadne-codegen +# Source: queries.graphql + +from typing import Literal, Optional, Union + +from pydantic import Field + +from .base_model import BaseModel +from .fragments import InvalidInputSelection, OperationErrorSelection, UserSelection + + +class UsersRemoveRoles(BaseModel): + admin: "UsersRemoveRolesAdmin" + + +class UsersRemoveRolesAdmin(BaseModel): + user: "UsersRemoveRolesAdminUser" + + +class UsersRemoveRolesAdminUser(BaseModel): + mutate: Optional["UsersRemoveRolesAdminUserMutate"] + + +class UsersRemoveRolesAdminUserMutate(BaseModel): + remove_roles: Union[ + "UsersRemoveRolesAdminUserMutateRemoveRolesUser", + "UsersRemoveRolesAdminUserMutateRemoveRolesInvalidInput", + "UsersRemoveRolesAdminUserMutateRemoveRolesOperationError", + ] = Field(alias="removeRoles", discriminator="typename__") + + +class UsersRemoveRolesAdminUserMutateRemoveRolesUser(UserSelection): + typename__: Literal["User"] = Field(alias="__typename") + + +class UsersRemoveRolesAdminUserMutateRemoveRolesInvalidInput(InvalidInputSelection): + typename__: Literal["InvalidInput"] = Field(alias="__typename") + + +class UsersRemoveRolesAdminUserMutateRemoveRolesOperationError(OperationErrorSelection): + typename__: Literal["OperationError"] = Field(alias="__typename") + + +UsersRemoveRoles.model_rebuild() +UsersRemoveRolesAdmin.model_rebuild() +UsersRemoveRolesAdminUser.model_rebuild() +UsersRemoveRolesAdminUserMutate.model_rebuild() diff --git a/api/python/quilt3/admin/_graphql_client/users_reset_password.py b/api/python/quilt3/admin/_graphql_client/users_reset_password.py new file mode 100644 index 00000000000..65b54546dc5 --- /dev/null +++ b/api/python/quilt3/admin/_graphql_client/users_reset_password.py @@ -0,0 +1,49 @@ +# Generated by ariadne-codegen +# Source: queries.graphql + +from typing import Literal, Optional, Union + +from pydantic import Field + +from .base_model import BaseModel +from .fragments import InvalidInputSelection, OperationErrorSelection + + +class UsersResetPassword(BaseModel): + admin: "UsersResetPasswordAdmin" + + +class UsersResetPasswordAdmin(BaseModel): + user: "UsersResetPasswordAdminUser" + + +class UsersResetPasswordAdminUser(BaseModel): + mutate: Optional["UsersResetPasswordAdminUserMutate"] + + +class UsersResetPasswordAdminUserMutate(BaseModel): + reset_password: Union[ + "UsersResetPasswordAdminUserMutateResetPasswordOk", + "UsersResetPasswordAdminUserMutateResetPasswordInvalidInput", + "UsersResetPasswordAdminUserMutateResetPasswordOperationError", + ] = Field(alias="resetPassword", discriminator="typename__") + + +class UsersResetPasswordAdminUserMutateResetPasswordOk(BaseModel): + typename__: Literal["Ok"] = Field(alias="__typename") + + +class UsersResetPasswordAdminUserMutateResetPasswordInvalidInput(InvalidInputSelection): + typename__: Literal["InvalidInput"] = Field(alias="__typename") + + +class UsersResetPasswordAdminUserMutateResetPasswordOperationError( + OperationErrorSelection +): + typename__: Literal["OperationError"] = Field(alias="__typename") + + +UsersResetPassword.model_rebuild() +UsersResetPasswordAdmin.model_rebuild() +UsersResetPasswordAdminUser.model_rebuild() +UsersResetPasswordAdminUserMutate.model_rebuild() diff --git a/api/python/quilt3/admin/_graphql_client/users_set_active.py b/api/python/quilt3/admin/_graphql_client/users_set_active.py new file mode 100644 index 00000000000..1d6b06056dc --- /dev/null +++ b/api/python/quilt3/admin/_graphql_client/users_set_active.py @@ -0,0 +1,47 @@ +# Generated by ariadne-codegen +# Source: queries.graphql + +from typing import Literal, Optional, Union + +from pydantic import Field + +from .base_model import BaseModel +from .fragments import InvalidInputSelection, OperationErrorSelection, UserSelection + + +class UsersSetActive(BaseModel): + admin: "UsersSetActiveAdmin" + + +class UsersSetActiveAdmin(BaseModel): + user: "UsersSetActiveAdminUser" + + +class UsersSetActiveAdminUser(BaseModel): + mutate: Optional["UsersSetActiveAdminUserMutate"] + + +class UsersSetActiveAdminUserMutate(BaseModel): + set_active: Union[ + "UsersSetActiveAdminUserMutateSetActiveUser", + "UsersSetActiveAdminUserMutateSetActiveInvalidInput", + "UsersSetActiveAdminUserMutateSetActiveOperationError", + ] = Field(alias="setActive", discriminator="typename__") + + +class UsersSetActiveAdminUserMutateSetActiveUser(UserSelection): + typename__: Literal["User"] = Field(alias="__typename") + + +class UsersSetActiveAdminUserMutateSetActiveInvalidInput(InvalidInputSelection): + typename__: Literal["InvalidInput"] = Field(alias="__typename") + + +class UsersSetActiveAdminUserMutateSetActiveOperationError(OperationErrorSelection): + typename__: Literal["OperationError"] = Field(alias="__typename") + + +UsersSetActive.model_rebuild() +UsersSetActiveAdmin.model_rebuild() +UsersSetActiveAdminUser.model_rebuild() +UsersSetActiveAdminUserMutate.model_rebuild() diff --git a/api/python/quilt3/admin/_graphql_client/users_set_admin.py b/api/python/quilt3/admin/_graphql_client/users_set_admin.py new file mode 100644 index 00000000000..67e3213265a --- /dev/null +++ b/api/python/quilt3/admin/_graphql_client/users_set_admin.py @@ -0,0 +1,47 @@ +# Generated by ariadne-codegen +# Source: queries.graphql + +from typing import Literal, Optional, Union + +from pydantic import Field + +from .base_model import BaseModel +from .fragments import InvalidInputSelection, OperationErrorSelection, UserSelection + + +class UsersSetAdmin(BaseModel): + admin: "UsersSetAdminAdmin" + + +class UsersSetAdminAdmin(BaseModel): + user: "UsersSetAdminAdminUser" + + +class UsersSetAdminAdminUser(BaseModel): + mutate: Optional["UsersSetAdminAdminUserMutate"] + + +class UsersSetAdminAdminUserMutate(BaseModel): + set_admin: Union[ + "UsersSetAdminAdminUserMutateSetAdminUser", + "UsersSetAdminAdminUserMutateSetAdminInvalidInput", + "UsersSetAdminAdminUserMutateSetAdminOperationError", + ] = Field(alias="setAdmin", discriminator="typename__") + + +class UsersSetAdminAdminUserMutateSetAdminUser(UserSelection): + typename__: Literal["User"] = Field(alias="__typename") + + +class UsersSetAdminAdminUserMutateSetAdminInvalidInput(InvalidInputSelection): + typename__: Literal["InvalidInput"] = Field(alias="__typename") + + +class UsersSetAdminAdminUserMutateSetAdminOperationError(OperationErrorSelection): + typename__: Literal["OperationError"] = Field(alias="__typename") + + +UsersSetAdmin.model_rebuild() +UsersSetAdminAdmin.model_rebuild() +UsersSetAdminAdminUser.model_rebuild() +UsersSetAdminAdminUserMutate.model_rebuild() diff --git a/api/python/quilt3/admin/_graphql_client/users_set_email.py b/api/python/quilt3/admin/_graphql_client/users_set_email.py new file mode 100644 index 00000000000..4e434d313f0 --- /dev/null +++ b/api/python/quilt3/admin/_graphql_client/users_set_email.py @@ -0,0 +1,47 @@ +# Generated by ariadne-codegen +# Source: queries.graphql + +from typing import Literal, Optional, Union + +from pydantic import Field + +from .base_model import BaseModel +from .fragments import InvalidInputSelection, OperationErrorSelection, UserSelection + + +class UsersSetEmail(BaseModel): + admin: "UsersSetEmailAdmin" + + +class UsersSetEmailAdmin(BaseModel): + user: "UsersSetEmailAdminUser" + + +class UsersSetEmailAdminUser(BaseModel): + mutate: Optional["UsersSetEmailAdminUserMutate"] + + +class UsersSetEmailAdminUserMutate(BaseModel): + set_email: Union[ + "UsersSetEmailAdminUserMutateSetEmailUser", + "UsersSetEmailAdminUserMutateSetEmailInvalidInput", + "UsersSetEmailAdminUserMutateSetEmailOperationError", + ] = Field(alias="setEmail", discriminator="typename__") + + +class UsersSetEmailAdminUserMutateSetEmailUser(UserSelection): + typename__: Literal["User"] = Field(alias="__typename") + + +class UsersSetEmailAdminUserMutateSetEmailInvalidInput(InvalidInputSelection): + typename__: Literal["InvalidInput"] = Field(alias="__typename") + + +class UsersSetEmailAdminUserMutateSetEmailOperationError(OperationErrorSelection): + typename__: Literal["OperationError"] = Field(alias="__typename") + + +UsersSetEmail.model_rebuild() +UsersSetEmailAdmin.model_rebuild() +UsersSetEmailAdminUser.model_rebuild() +UsersSetEmailAdminUserMutate.model_rebuild() diff --git a/api/python/quilt3/admin/_graphql_client/users_set_role.py b/api/python/quilt3/admin/_graphql_client/users_set_role.py new file mode 100644 index 00000000000..e5af3f1b997 --- /dev/null +++ b/api/python/quilt3/admin/_graphql_client/users_set_role.py @@ -0,0 +1,47 @@ +# Generated by ariadne-codegen +# Source: queries.graphql + +from typing import Literal, Optional, Union + +from pydantic import Field + +from .base_model import BaseModel +from .fragments import InvalidInputSelection, OperationErrorSelection, UserSelection + + +class UsersSetRole(BaseModel): + admin: "UsersSetRoleAdmin" + + +class UsersSetRoleAdmin(BaseModel): + user: "UsersSetRoleAdminUser" + + +class UsersSetRoleAdminUser(BaseModel): + mutate: Optional["UsersSetRoleAdminUserMutate"] + + +class UsersSetRoleAdminUserMutate(BaseModel): + set_role: Union[ + "UsersSetRoleAdminUserMutateSetRoleUser", + "UsersSetRoleAdminUserMutateSetRoleInvalidInput", + "UsersSetRoleAdminUserMutateSetRoleOperationError", + ] = Field(alias="setRole", discriminator="typename__") + + +class UsersSetRoleAdminUserMutateSetRoleUser(UserSelection): + typename__: Literal["User"] = Field(alias="__typename") + + +class UsersSetRoleAdminUserMutateSetRoleInvalidInput(InvalidInputSelection): + typename__: Literal["InvalidInput"] = Field(alias="__typename") + + +class UsersSetRoleAdminUserMutateSetRoleOperationError(OperationErrorSelection): + typename__: Literal["OperationError"] = Field(alias="__typename") + + +UsersSetRole.model_rebuild() +UsersSetRoleAdmin.model_rebuild() +UsersSetRoleAdminUser.model_rebuild() +UsersSetRoleAdminUserMutate.model_rebuild() diff --git a/api/python/quilt3/admin/exceptions.py b/api/python/quilt3/admin/exceptions.py new file mode 100644 index 00000000000..c9f24ca6fcb --- /dev/null +++ b/api/python/quilt3/admin/exceptions.py @@ -0,0 +1,9 @@ +class Quilt3AdminError(Exception): + def __init__(self, details): + super().__init__(details) + self.details = details + + +class UserNotFoundError(Quilt3AdminError): + def __init__(self): + super().__init__(None) diff --git a/api/python/quilt3/admin/roles.py b/api/python/quilt3/admin/roles.py new file mode 100644 index 00000000000..ec7b6450919 --- /dev/null +++ b/api/python/quilt3/admin/roles.py @@ -0,0 +1,10 @@ +from typing import List + +from . import types, util + + +def list() -> List[types.Role]: + """ + Get a list of all roles in the registry. + """ + return [types.role_adapter.validate_python(r.model_dump()) for r in util.get_client().roles_list()] diff --git a/api/python/quilt3/admin/types.py b/api/python/quilt3/admin/types.py new file mode 100644 index 00000000000..959a3c00601 --- /dev/null +++ b/api/python/quilt3/admin/types.py @@ -0,0 +1,39 @@ +from datetime import datetime +from typing import Annotated, List, Literal, Optional, Union + +import pydantic + + +@pydantic.dataclasses.dataclass +class ManagedRole: + id: str + name: str + arn: str + typename__: Literal["ManagedRole"] + + +@pydantic.dataclasses.dataclass +class UnmanagedRole: + id: str + name: str + arn: str + typename__: Literal["UnmanagedRole"] + + +Role = Union[ManagedRole, UnmanagedRole] +AnnotatedRole = Annotated[Role, pydantic.Field(discriminator="typename__")] +role_adapter = pydantic.TypeAdapter(AnnotatedRole) + + +@pydantic.dataclasses.dataclass +class User: + name: str + email: str + date_joined: datetime + last_login: datetime + is_active: bool + is_admin: bool + is_sso_only: bool + is_service: bool + role: Optional[AnnotatedRole] + extra_roles: List[AnnotatedRole] diff --git a/api/python/quilt3/admin/users.py b/api/python/quilt3/admin/users.py new file mode 100644 index 00000000000..fb667de9de8 --- /dev/null +++ b/api/python/quilt3/admin/users.py @@ -0,0 +1,165 @@ + +from typing import List, Optional + +from . import _graphql_client, exceptions, types, util + + +def get(name: str) -> Optional[types.User]: + """ + Get a specific user from the registry. Return `None` if the user does not exist. + + Args: + name: Username of user to get. + """ + result = util.get_client().users_get(name=name) + if result is None: + return None + return types.User(**result.model_dump()) + + +def list() -> List[types.User]: + """ + Get a list of all users in the registry. + """ + return [types.User(**u.model_dump()) for u in util.get_client().users_list()] + + +def create(name: str, email: str, role: str, extra_roles: Optional[List[str]] = None) -> types.User: + """ + Create a new user in the registry. + + Args: + name: Username of user to create. + email: Email of user to create. + role: Active role of the user. + extra_roles: Additional roles to assign to the user. + """ + + return util.handle_user_mutation( + util.get_client().users_create( + input=_graphql_client.UserInput(name=name, email=email, role=role, extraRoles=extra_roles) + ) + ) + + +def delete(name: str) -> None: + """ + Delete user from the registry. + + Args: + name: Username of user to delete. + """ + result = util.get_client().users_delete(name=name) + if result is None: + raise exceptions.UserNotFoundError + util.handle_errors(result.delete) + + +def set_email(name: str, email: str) -> types.User: + """ + Set the email for a user. + + Args: + name: Username of user to update. + email: Email to set for the user. + """ + result = util.get_client().users_set_email(name=name, email=email) + if result is None: + raise exceptions.UserNotFoundError + return util.handle_user_mutation(result.set_email) + + +def set_admin(name: str, admin: bool) -> types.User: + """ + Set the admin status for a user. + + Args: + name: Username of user to update. + admin: Admin status to set for the user. + """ + result = util.get_client().users_set_admin(name=name, admin=admin) + if result is None: + raise exceptions.UserNotFoundError + return util.handle_user_mutation(result.set_admin) + + +def set_active(name: str, active: bool) -> types.User: + """ + Set the active status for a user. + + Args: + name: Username of user to update. + active: Active status to set for the user. + """ + result = util.get_client().users_set_active(name=name, active=active) + if result is None: + raise exceptions.UserNotFoundError + return util.handle_user_mutation(result.set_active) + + +def reset_password(name: str) -> None: + """ + Reset the password for a user. + + Args: + name: Username of user to update. + """ + result = util.get_client().users_reset_password(name=name) + if result is None: + raise exceptions.UserNotFoundError + util.handle_errors(result.reset_password) + + +def set_role( + name: str, + role: str, + extra_roles: Optional[List[str]] = None, + *, + append: bool = False, +) -> types.User: + """ + Set the active and extra roles for a user. + + Args: + name: Username of user to update. + role: Role to be set as the active role. + extra_roles: Additional roles to assign to the user. + append: If True, append the extra roles to the existing roles. If False, replace the existing roles. + """ + result = util.get_client().users_set_role(name=name, role=role, extra_roles=extra_roles, append=append) + if result is None: + raise exceptions.UserNotFoundError + return util.handle_user_mutation(result.set_role) + + +def add_roles(name: str, roles: List[str]) -> types.User: + """ + Add roles to a user. + + Args: + name: Username of user to update. + roles: Roles to add to the user. + """ + result = util.get_client().users_add_roles(name=name, roles=roles) + if result is None: + raise exceptions.UserNotFoundError + return util.handle_user_mutation(result.add_roles) + + +def remove_roles( + name: str, + roles: List[str], + fallback: Optional[str] = None, +) -> types.User: + """ + Remove roles from a user. + + Args: + name: Username of user to update. + roles: Roles to remove from the user. + fallback: If set, the role to assign to the user if the active role is removed. + """ + result = util.get_client().users_remove_roles(name=name, roles=roles, fallback=fallback) + if result is None: + raise exceptions.UserNotFoundError + return util.handle_user_mutation(result.remove_roles) diff --git a/api/python/quilt3/admin/util.py b/api/python/quilt3/admin/util.py new file mode 100644 index 00000000000..bf3a3f9c988 --- /dev/null +++ b/api/python/quilt3/admin/util.py @@ -0,0 +1,15 @@ +from . import _graphql_client, exceptions, types + + +def handle_errors(result: _graphql_client.BaseModel) -> _graphql_client.BaseModel: + if isinstance(result, (_graphql_client.InvalidInputSelection, _graphql_client.OperationErrorSelection)): + raise exceptions.Quilt3AdminError(result) + return result + + +def handle_user_mutation(result: _graphql_client.BaseModel) -> types.User: + return types.User(**handle_errors(result).model_dump()) + + +def get_client(): + return _graphql_client.Client() diff --git a/api/python/setup.py b/api/python/setup.py index 130dbfa9463..fec06d22ea9 100644 --- a/api/python/setup.py +++ b/api/python/setup.py @@ -65,6 +65,7 @@ def run(self): 'tqdm>=4.32', 'requests_futures==1.0.0', 'jsonschema>=3,<5', + "pydantic>=2.0.0,<3.0.0", ], extras_require={ 'pyarrow': [ diff --git a/api/python/tests/test_admin_api.py b/api/python/tests/test_admin_api.py new file mode 100644 index 00000000000..69446664b1b --- /dev/null +++ b/api/python/tests/test_admin_api.py @@ -0,0 +1,326 @@ +import contextlib +import datetime +from unittest import mock + +import pytest + +from quilt3 import admin +from quilt3.admin import _graphql_client + +UNMANAGED_ROLE = { + "__typename": "UnmanagedRole", + "id": "d7d15bef-c482-4086-ae6b-d0372b6145d2", + "name": "UnmanagedRole", + "arn": "arn:aws:iam::000000000000:role/UnmanagedRole", +} +MANAGED_ROLE = { + "__typename": "ManagedRole", + "id": "b1bab604-98fd-4b46-a20b-958cf2541c91", + "name": "ManagedRole", + "arn": "arn:aws:iam::000000000000:role/ManagedRole", +} +USER = { + "__typename": "User", + "name": "test", + "email": "test@example.com", + "dateJoined": datetime.datetime(2024, 6, 14, 11, 42, 27, 857128, tzinfo=datetime.timezone.utc), + "lastLogin": datetime.datetime(2024, 6, 14, 11, 42, 27, 857128, tzinfo=datetime.timezone.utc), + "isActive": True, + "isAdmin": False, + "isSsoOnly": False, + "isService": False, + "role": UNMANAGED_ROLE, + "extraRoles": [MANAGED_ROLE], +} +MUTATION_ERRORS = ( + ( + { + "__typename": "InvalidInput", + "errors": [ + { + "path": "error path", + "message": "error message", + "name": "error name", + "context": {}, + } + ], + }, + admin.Quilt3AdminError, + ), + ( + { + "__typename": "OperationError", + "message": "error message", + "name": "error name", + "context": {}, + }, + admin.Quilt3AdminError, + ), +) +USER_MUTATION_ERRORS = ( + *MUTATION_ERRORS, + (None, admin.UserNotFoundError), +) + + +def _camel_to_snake(name: str) -> str: + return "".join("_" + c.lower() if c.isupper() else c for c in name).lstrip("_") + + +def _as_dataclass_kwargs(data: dict) -> dict: + return { + "typename__" if k == "__typename" else _camel_to_snake(k): ( + _as_dataclass_kwargs(v) + if isinstance(v, dict) + else [_as_dataclass_kwargs(x) for x in v] if isinstance(v, list) else v + ) + for k, v in data.items() + } + + +def _make_nested_dict(path: str, value) -> dict: + if "." in path: + key, rest = path.split(".", 1) + return {key: _make_nested_dict(rest, value)} + return {path: value} + + +@contextlib.contextmanager +def mock_client(data, operation_name, variables=None): + with mock.patch("quilt3.session.get_registry_url", return_value="https://registry.example.com"): + with mock.patch( + "quilt3.admin._graphql_client.Client.execute", return_value=mock.sentinel.RESPONSE + ) as execute_mock: + with mock.patch("quilt3.admin._graphql_client.Client.get_data", return_value=data) as get_data_mock: + yield + + execute_mock.assert_called_once_with(query=mock.ANY, operation_name=operation_name, variables=variables or {}) + get_data_mock.assert_called_once_with(mock.sentinel.RESPONSE) + + +def test_get_roles(): + with mock_client({"roles": [UNMANAGED_ROLE, MANAGED_ROLE]}, "rolesList"): + assert admin.roles.list() == [ + admin.UnmanagedRole(**_as_dataclass_kwargs(UNMANAGED_ROLE)), + admin.ManagedRole(**_as_dataclass_kwargs(MANAGED_ROLE)), + ] + + +@pytest.mark.parametrize( + "data,result", + [ + (USER, admin.User(**_as_dataclass_kwargs(USER))), + (None, None), + ], +) +def test_get_user(data, result): + with mock_client(_make_nested_dict("admin.user.get", data), "usersGet", variables={"name": "test"}): + assert admin.users.get("test") == result + + +def test_get_users(): + with mock_client(_make_nested_dict("admin.user.list", [USER]), "usersList"): + assert admin.users.list() == [admin.User(**_as_dataclass_kwargs(USER))] + + +@pytest.mark.parametrize( + "data,result", + [ + (USER, admin.User(**_as_dataclass_kwargs(USER))), + *MUTATION_ERRORS, + ], +) +def test_create_user(data, result): + with mock_client( + _make_nested_dict("admin.user.create", data), + "usersCreate", + variables={ + "input": _graphql_client.UserInput( + name="test", email="test@example.com", role="UnmanagedRole", extraRoles=[] + ) + }, + ): + if isinstance(result, type) and issubclass(result, Exception): + with pytest.raises(result): + admin.users.create("test", "test@example.com", "UnmanagedRole", []) + else: + assert admin.users.create("test", "test@example.com", "UnmanagedRole", []) == result + + +@pytest.mark.parametrize( + "data,result", + MUTATION_ERRORS, +) +def test_delete_user(data, result): + with mock_client( + _make_nested_dict("admin.user.mutate.delete", data), + "usersDelete", + variables={"name": "test"}, + ): + with pytest.raises(result): + admin.users.delete("test") + + +@pytest.mark.parametrize( + "data,result", + [ + (USER, admin.User(**_as_dataclass_kwargs(USER))), + *USER_MUTATION_ERRORS, + ], +) +def test_set_user_email(data, result): + with mock_client( + ( + _make_nested_dict("admin.user.mutate", None) + if data is None + else _make_nested_dict("admin.user.mutate.setEmail", data) + ), + "usersSetEmail", + variables={"name": "test", "email": "test@example.com"}, + ): + if isinstance(result, type) and issubclass(result, Exception): + with pytest.raises(result): + admin.users.set_email("test", "test@example.com") + else: + assert admin.users.set_email("test", "test@example.com") == result + + +@pytest.mark.parametrize( + "data,result", + [ + (USER, admin.User(**_as_dataclass_kwargs(USER))), + *USER_MUTATION_ERRORS, + ], +) +def test_set_user_admin(data, result): + with mock_client( + ( + _make_nested_dict("admin.user.mutate", None) + if data is None + else _make_nested_dict("admin.user.mutate.setAdmin", data) + ), + "usersSetAdmin", + variables={"name": "test", "admin": True}, + ): + if isinstance(result, type) and issubclass(result, Exception): + with pytest.raises(result): + admin.users.set_admin("test", True) + else: + assert admin.users.set_admin("test", True) == result + + +@pytest.mark.parametrize( + "data,result", + [ + (USER, admin.User(**_as_dataclass_kwargs(USER))), + *USER_MUTATION_ERRORS, + ], +) +def test_set_user_active(data, result): + with mock_client( + ( + _make_nested_dict("admin.user.mutate", None) + if data is None + else _make_nested_dict("admin.user.mutate.setActive", data) + ), + "usersSetActive", + variables={"name": "test", "active": True}, + ): + if isinstance(result, type) and issubclass(result, Exception): + with pytest.raises(result): + admin.users.set_active("test", True) + else: + assert admin.users.set_active("test", True) == result + + +@pytest.mark.parametrize( + "data,result", + USER_MUTATION_ERRORS, +) +def test_reset_user_password(data, result): + with mock_client( + ( + _make_nested_dict("admin.user.mutate", None) + if data is None + else _make_nested_dict("admin.user.mutate.resetPassword", data) + ), + "usersResetPassword", + variables={"name": "test"}, + ): + if isinstance(result, type) and issubclass(result, Exception): + with pytest.raises(result): + admin.users.reset_password("test") + else: + assert admin.users.reset_password("test") == result + + +@pytest.mark.parametrize( + "data,result", + [ + (USER, admin.User(**_as_dataclass_kwargs(USER))), + *USER_MUTATION_ERRORS, + ], +) +def test_set_role(data, result): + with mock_client( + ( + _make_nested_dict("admin.user.mutate", None) + if data is None + else _make_nested_dict("admin.user.mutate.setRole", data) + ), + "usersSetRole", + variables={"name": "test", "role": "UnamangedRole", "extraRoles": [], "append": True}, + ): + if isinstance(result, type) and issubclass(result, Exception): + with pytest.raises(result): + admin.users.set_role("test", "UnamangedRole", [], append=True) + else: + assert admin.users.set_role("test", "UnamangedRole", [], append=True) == result + + +@pytest.mark.parametrize( + "data,result", + [ + (USER, admin.User(**_as_dataclass_kwargs(USER))), + *USER_MUTATION_ERRORS, + ], +) +def test_add_roles(data, result): + with mock_client( + ( + _make_nested_dict("admin.user.mutate", None) + if data is None + else _make_nested_dict("admin.user.mutate.addRoles", data) + ), + "usersAddRoles", + variables={"name": "test", "roles": ["ManagedRole"]}, + ): + if isinstance(result, type) and issubclass(result, Exception): + with pytest.raises(result): + admin.users.add_roles("test", ["ManagedRole"]) + else: + assert admin.users.add_roles("test", ["ManagedRole"]) == result + + +@pytest.mark.parametrize( + "data,result", + [ + (USER, admin.User(**_as_dataclass_kwargs(USER))), + *USER_MUTATION_ERRORS, + ], +) +def test_remove_roles(data, result): + with mock_client( + ( + _make_nested_dict("admin.user.mutate", None) + if data is None + else _make_nested_dict("admin.user.mutate.removeRoles", data) + ), + "usersRemoveRoles", + variables={"name": "test", "roles": ["ManagedRole"], "fallback": "UnamanagedRole"}, + ): + if isinstance(result, type) and issubclass(result, Exception): + with pytest.raises(result): + admin.users.remove_roles("test", ["ManagedRole"], fallback="UnamanagedRole") + else: + assert admin.users.remove_roles("test", ["ManagedRole"], fallback="UnamanagedRole") == result diff --git a/api/python/tests/test_api.py b/api/python/tests/test_api.py index 549495dbecd..0aa18e16493 100644 --- a/api/python/tests/test_api.py +++ b/api/python/tests/test_api.py @@ -42,18 +42,3 @@ def test_config_invalid_host(self): # present. ..but, a bad port causes an error.. with pytest.raises(util.QuiltException, match='Port must be a number'): he.config('https://fliff:fluff') - - def test_set_role(self): - self.requests_mock.add(responses.POST, DEFAULT_URL + '/api/users/set_role', - json={}, status=200) - - not_found_result = { - 'message': "No user exists by the provided name." - } - self.requests_mock.add(responses.POST, DEFAULT_URL + '/api/users/set_role', - json=not_found_result, status=400) - - he.admin.set_role(username='test_user', role_name='test_role') - - with pytest.raises(util.QuiltException): - he.admin.set_role(username='not_found', role_name='test_role') diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 82528906f1d..a27dc5ee408 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -17,6 +17,8 @@ Entries inside each section should be ordered by type: # unreleased - YYYY-MM-DD ## Python API +* [Added] New `quilt3.admin` API with more features (requires 1.53+ stack) ([#3990](https://github.com/quiltdata/quilt/pull/3990)) +* [Removed] `quilt3.admin` API ([#3990](https://github.com/quiltdata/quilt/pull/3990)) * [Removed] Drop Python 3.8 support ([#3993](https://github.com/quiltdata/quilt/pull/3993)) * [Fixed] If upload optimization during `push()` succeeds the checksum is calculated from local file instead of remote file ([#3968](https://github.com/quiltdata/quilt/pull/3968)) * [Changed] Upload optimization check now tries to use S3 SHA-256 checksum and falls back to ETag ([#3968](https://github.com/quiltdata/quilt/pull/3968)) diff --git a/docs/api-reference/Admin.md b/docs/api-reference/Admin.md index 9f6183c3c0b..2ac177a5daf 100644 --- a/docs/api-reference/Admin.md +++ b/docs/api-reference/Admin.md @@ -1,29 +1,130 @@ -# quilt3.admin -APIs for Quilt administrators. 'Registry' refers to Quilt stack backend services, including identity management. +# quilt3.admin.types -## create\_user(\*, username: str, email: str) {#create\_user} + +## ManagedRole(id: str, name: str, arn: str, typename\_\_: Literal['ManagedRole']) -> None {#ManagedRole} + + +## UnmanagedRole(id: str, name: str, arn: str, typename\_\_: Literal['UnmanagedRole']) -> None {#UnmanagedRole} + + +## User(name: str, email: str, date\_joined: datetime.datetime, last\_login: datetime.datetime, is\_active: bool, is\_admin: bool, is\_sso\_only: bool, is\_service: bool, role: Optional[Annotated[Union[quilt3.admin.types.ManagedRole, quilt3.admin.types.UnmanagedRole], FieldInfo(annotation=NoneType, required=True, discriminator='typename\_\_')]], extra\_roles: List[Annotated[Union[quilt3.admin.types.ManagedRole, quilt3.admin.types.UnmanagedRole], FieldInfo(annotation=NoneType, required=True, discriminator='typename\_\_')]]) -> None {#User} + + +# quilt3.admin.roles + + +## list() -> List[Union[quilt3.admin.types.ManagedRole, quilt3.admin.types.UnmanagedRole]] {#list} + +Get a list of all roles in the registry. + + +# quilt3.admin.users + + +## get(name: str) -> Optional[quilt3.admin.types.User] {#get} + +Get a specific user from the registry. Return `None` if the user does not exist. + +__Arguments__ + +* __name__: Username of user to get. + + +## list() -> List[quilt3.admin.types.User] {#list} + +Get a list of all users in the registry. + + +## create(name: str, email: str, role: str, extra\_roles: Optional[List[str]] = None) -> quilt3.admin.types.User {#create} Create a new user in the registry. -Required parameters: - username (str): Username of user to create. - email (str): Email of user to create. +__Arguments__ + +* __name__: Username of user to create. +* __email__: Email of user to create. +* __role__: Active role of the user. +* __extra_roles__: Additional roles to assign to the user. -## delete\_user(\*, username: str) {#delete\_user} +## delete(name: str) -> None {#delete} Delete user from the registry. -Required parameters: - username (str): Username of user to delete. +__Arguments__ + +* __name__: Username of user to delete. + + +## set\_email(name: str, email: str) -> quilt3.admin.types.User {#set\_email} + +Set the email for a user. + +__Arguments__ + +* __name__: Username of user to update. +* __email__: Email to set for the user. + + +## set\_admin(name: str, admin: bool) -> quilt3.admin.types.User {#set\_admin} + +Set the admin status for a user. + +__Arguments__ + +* __name__: Username of user to update. +* __admin__: Admin status to set for the user. + + +## set\_active(name: str, active: bool) -> quilt3.admin.types.User {#set\_active} + +Set the active status for a user. + +__Arguments__ + +* __name__: Username of user to update. +* __active__: Active status to set for the user. + + +## reset\_password(name: str) -> None {#reset\_password} + +Reset the password for a user. + +__Arguments__ + +* __name__: Username of user to update. + + +## set\_role(name: str, role: str, extra\_roles: Optional[List[str]] = None, \*, append: bool = False) -> quilt3.admin.types.User {#set\_role} + +Set the active and extra roles for a user. + +__Arguments__ + +* __name__: Username of user to update. +* __role__: Role to be set as the active role. +* __extra_roles__: Additional roles to assign to the user. +* __append__: If True, append the extra roles to the existing roles. If False, replace the existing roles. + + +## add\_roles(name: str, roles: List[str]) -> quilt3.admin.types.User {#add\_roles} + +Add roles to a user. + +__Arguments__ + +* __name__: Username of user to update. +* __roles__: Roles to add to the user. + +## remove\_roles(name: str, roles: List[str], fallback: Optional[str] = None) -> quilt3.admin.types.User {#remove\_roles} -## set\_role(\*, username: str, role\_name: Optional[str]) {#set\_role} +Remove roles from a user. -Set the named Quilt role for a user. +__Arguments__ -Required parameters: - username (str): Username of user to update. - role_name (str): Quilt role name assign to the user. Set a `None` value to unassign the role. +* __name__: Username of user to update. +* __roles__: Roles to remove from the user. +* __fallback__: If set, the role to assign to the user if the active role is removed. diff --git a/gendocs/pydocmd.yml b/gendocs/pydocmd.yml index da211f1b4e7..6c0e1253f99 100644 --- a/gendocs/pydocmd.yml +++ b/gendocs/pydocmd.yml @@ -31,7 +31,9 @@ generate: - quilt3.packages.PackageEntry+ - Admin.md: - - quilt3.admin+ + - quilt3.admin.types+ + - quilt3.admin.roles+ + - quilt3.admin.users+ # MkDocs pages configuration. The `<<` operator is sugar added by pydocmd # that allows you to use an external Markdown file (eg. your project's README) diff --git a/pylintrc b/pylintrc index ef532bd8e4e..4f862167463 100644 --- a/pylintrc +++ b/pylintrc @@ -1,3 +1,6 @@ +[MASTER] +ignore-paths=.*/api/python/quilt3/admin/_graphql_client/.* + [MESSAGES CONTROL] disable=attribute-defined-outside-init, broad-except, diff --git a/setup.cfg b/setup.cfg index 59bb2d5a621..05d42ea30cc 100644 --- a/setup.cfg +++ b/setup.cfg @@ -1,7 +1,9 @@ [pycodestyle] max-line-length = 119 +exclude=api/python/quilt3/admin/_graphql_client/* [isort] known_first_party = quilt*,t4_lambda_* include_trailing_comma = true multi_line_output=3 +extend_skip=api/python/quilt3/admin/_graphql_client/ diff --git a/shared/graphql/schema.graphql b/shared/graphql/schema.graphql index e04433be231..910dccfa0ea 100644 --- a/shared/graphql/schema.graphql +++ b/shared/graphql/schema.graphql @@ -37,6 +37,8 @@ type Ok { _: Boolean } +union OperationResult = Ok | InvalidInput | OperationError + type Unavailable { _: Boolean } @@ -189,6 +191,19 @@ type RoleBucketPermission implements BucketPermission { level: BucketPermissionLevel! } +type User { + name: String! + email: String! + dateJoined: Datetime! + lastLogin: Datetime! + isActive: Boolean! + isAdmin: Boolean! + isSsoOnly: Boolean! + isService: Boolean! + role: Role + extraRoles: [Role!]! +} + type AccessCountForDate { date: Datetime! value: Int! @@ -477,7 +492,32 @@ type SubscriptionState { timestamp: Datetime! } +type UserAdminQueries { + list: [User!]! + get(name: String!): User +} + +type AdminQueries { + user: UserAdminQueries! +} + +type MyRole { + name: String! +} + +type Me { + name: String! + email: String! + isAdmin: Boolean! + role: MyRole! + roles: [MyRole!]! +} + +union SwitchRoleResult = Me | InvalidInput | OperationError + type Query { + me: Me + config: Config! bucketConfigs: [BucketConfig!]! bucketConfig(name: String!): BucketConfig @@ -498,6 +538,9 @@ type Query { searchMoreObjects(after: String!, size: Int = 30): ObjectsSearchMoreResult! searchMorePackages(after: String!, size: Int = 30): PackagesSearchMoreResult! subscription: SubscriptionState! + + admin: AdminQueries! @admin + policies: [Policy!]! @admin policy(id: ID!): Policy @admin roles: [Role!]! @admin @@ -792,7 +835,38 @@ union BrowsingSessionRefreshResult = union BrowsingSessionDisposeResult = Ok | OperationError +input UserInput { + name: String! + email: String! + role: String! + extraRoles: [String!] +} + +union UserResult = User | InvalidInput | OperationError + +type MutateUserAdminMutations { + delete: OperationResult! + setEmail(email: String!): UserResult! + setRole(role: String!, extraRoles: [String!], append: Boolean! = false): UserResult! + addRoles(roles: [String!]!): UserResult! + removeRoles(roles: [String!]!, fallback: String): UserResult! + setAdmin(admin: Boolean!): UserResult! + setActive(active: Boolean!): UserResult! + resetPassword: OperationResult! +} + +type UserAdminMutations { + create(input: UserInput!): UserResult! + mutate(name: String!): MutateUserAdminMutations +} + +type AdminMutations { + user: UserAdminMutations! +} + type Mutation { + switchRole(roleName: String!): SwitchRoleResult! + packageConstruct( params: PackagePushParams! src: PackageConstructSource! @@ -807,6 +881,8 @@ type Mutation { hash: String! ): PackageRevisionDeleteResult! + admin: AdminMutations! @admin + bucketAdd(input: BucketAddInput!): BucketAddResult! @admin bucketUpdate(name: String!, input: BucketUpdateInput!): BucketUpdateResult! @admin From 1b8d91de0e58c4b79171b1ffc2ed89423b7e979a Mon Sep 17 00:00:00 2001 From: Sergey Fedoseev Date: Tue, 18 Jun 2024 10:22:48 +0400 Subject: [PATCH 5/5] Quilt3 v6.0.0a4 (#4007) --- api/python/quilt3/VERSION | 2 +- docs/CHANGELOG.md | 4 +--- 2 files changed, 2 insertions(+), 4 deletions(-) diff --git a/api/python/quilt3/VERSION b/api/python/quilt3/VERSION index 673cb180400..9a230733aa2 100644 --- a/api/python/quilt3/VERSION +++ b/api/python/quilt3/VERSION @@ -1 +1 @@ -6.0.0a3 +6.0.0a4 diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index a27dc5ee408..c752a425337 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -14,7 +14,7 @@ Entries inside each section should be ordered by type: ## Catalog, Lambdas !--> -# unreleased - YYYY-MM-DD +# 6.0.0a4 - 2024-06-18 ## Python API * [Added] New `quilt3.admin` API with more features (requires 1.53+ stack) ([#3990](https://github.com/quiltdata/quilt/pull/3990)) @@ -23,8 +23,6 @@ Entries inside each section should be ordered by type: * [Fixed] If upload optimization during `push()` succeeds the checksum is calculated from local file instead of remote file ([#3968](https://github.com/quiltdata/quilt/pull/3968)) * [Changed] Upload optimization check now tries to use S3 SHA-256 checksum and falls back to ETag ([#3968](https://github.com/quiltdata/quilt/pull/3968)) -## CLI - ## Catalog, Lambdas * [Changed] Use promises for URLs in IGV to have fresh signing each time they used ([#3979](https://github.com/quiltdata/quilt/pull/3979))