diff --git a/.github/workflows/integration_tests.yml b/.github/workflows/integration_tests.yml new file mode 100644 index 0000000..bc79028 --- /dev/null +++ b/.github/workflows/integration_tests.yml @@ -0,0 +1,92 @@ +name: Integration tests + +on: + workflow_dispatch: + pull_request: + branches: [main] + types: [opened, synchronize, reopened] + paths-ignore: + - "**/*.md" + - "docs/**" + +concurrency: + group: integration-${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + +env: + MILVUS_URI: http://127.0.0.1:19530 + +jobs: + integration: + runs-on: ubuntu-latest + timeout-minutes: 30 + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-python@v5 + with: + python-version: "3.11" + + - name: Resolve latest stable Milvus image tag + id: milvus + run: | + TAG=$(curl -sL https://api.github.com/repos/milvus-io/milvus/releases/latest | python3 -c "import sys, json; print(json.load(sys.stdin)['tag_name'])") + echo "tag=${TAG}" >> "$GITHUB_OUTPUT" + echo "Using Milvus release ${TAG}" + + - name: Install pymilvus (readiness probe) + run: | + python -m pip install --upgrade pip + pip install "pymilvus>=2.6.0" + + - name: Start Milvus (standalone) + env: + MILVUS_IMAGE_TAG: ${{ steps.milvus.outputs.tag }} + run: | + wget -q https://raw.githubusercontent.com/milvus-io/milvus/master/deployments/docker/standalone/docker-compose.yml -O docker-compose.yml + sed -i "s|milvusdb/milvus:.*|milvusdb/milvus:${MILVUS_IMAGE_TAG}|g" docker-compose.yml + docker compose up -d + + - name: Wait for MinIO + run: | + for i in $(seq 1 30); do + if curl -sf http://127.0.0.1:9000/minio/health/live; then + echo "MinIO is ready" + exit 0 + fi + echo "attempt $i/30..." + sleep 2 + done + exit 1 + + - name: Wait for Milvus + run: | + python << 'EOF' + import time + from pymilvus import connections + + for _ in range(60): + try: + connections.connect(uri="http://127.0.0.1:19530") + connections.disconnect("default") + print("Milvus is ready") + break + except Exception: + time.sleep(2) + else: + raise SystemExit("Milvus did not become ready in time") + EOF + + - name: Install package and pytest + run: | + pip install pytest + pip install . + + - name: Run integration tests + env: + MILVUS_URI: ${{ env.MILVUS_URI }} + run: pytest tests/ -v --tb=short + + - name: Print Docker logs on failure + if: failure() + run: docker compose logs --no-color || true diff --git a/milvus_cli/scripts/data_client_cli.py b/milvus_cli/scripts/data_client_cli.py index da846be..435d420 100644 --- a/milvus_cli/scripts/data_client_cli.py +++ b/milvus_cli/scripts/data_client_cli.py @@ -26,11 +26,26 @@ help="[Optional] - Name of partitions that contain entities.", default=None, ) +@click.option( + "-e", + "--expr", + "expr_opt", + default=None, + help="Filter expression (non-interactive; skips prompt when set with --yes).", +) +@click.option( + "--yes", + "-y", + is_flag=True, + help="Skip confirmation prompt (use with -e for scripting/CI).", +) @click.pass_obj def delete_entities( obj, collectionName, partitionName, + expr_opt, + yes, ): """ Delete entities using filter expression. @@ -55,33 +70,54 @@ def delete_entities( milvus_cli > delete entities -c products Expression: id in [100, 101, 102] - # Delete by condition - milvus_cli > delete entities -c products - Expression: status == "deleted" and updated_at < 1704067200 + # Non-interactive (CI/scripts) + milvus_cli > delete entities -c products -e 'id == 100' --yes SEE ALSO: delete ids, query """ - expr = click.prompt( - '''The expression to specify entities to be deleted, such as "film_id in [ 0, 1 ]"''' - ) - click.echo( - "You are trying to delete the entities of collection. This action cannot be undone!\n" - ) - if not click.confirm("Do you want to continue?"): - return + if expr_opt: + expr = expr_opt + else: + expr = click.prompt( + '''The expression to specify entities to be deleted, such as "film_id in [ 0, 1 ]"''' + ) + if not yes: + click.echo( + "You are trying to delete the entities of collection. This action cannot be undone!\n" + ) + if not click.confirm("Do you want to continue?"): + return result = obj.data.delete_entities(expr, collectionName, partitionName) click.echo(result) @cli.command("query") +@click.option( + "-c", + "--collection-name", + "collectionName_opt", + default=None, + help="Collection name (with -e/--expr skips prompts).", +) +@click.option( + "-e", + "--expr", + "expr_opt", + default=None, + help="Query filter expression (e.g. 'id > 0').", +) @click.pass_obj -def query(obj): +def query(obj, collectionName_opt, expr_opt): """ Query entities with filter expressions. USAGE: milvus_cli > query + milvus_cli > query -c my_coll -e 'id >= 0' + + NON-INTERACTIVE: + Pass -c and -e to run without prompts. INTERACTIVE PROMPTS: Collection name Select from available collections @@ -112,6 +148,28 @@ def query(obj): SEE ALSO: search, get, set output """ + if collectionName_opt and expr_opt: + collectionName = collectionName_opt + expr = expr_opt + try: + queryParameters = validateQueryParams( + expr, + "", + "", + 0, + 5, + "", + ) + except ParameterException as pe: + click.echo("Error!\n{}".format(str(pe))) + return + results = obj.data.query(collectionName, queryParameters) + if results: + click.echo(obj.formatter.format_output(results)) + else: + click.echo("No results found.") + return + collectionName = click.prompt( "Collection name", type=click.Choice(obj.collection.list_collections()) ) @@ -823,13 +881,46 @@ def hybrid_search(obj): @cli.command("search") +@click.option( + "-c", + "--collection-name", + "collectionName_opt", + default=None, + help="Collection name (with -f/-v/-l skips prompts for dense vector search).", +) +@click.option( + "-f", + "--field", + "annsField_opt", + default=None, + help="Vector field name.", +) +@click.option( + "-v", + "--vector", + "vector_json", + default=None, + help="Query vector as JSON array, e.g. '[0.1,0.2,0.3,0.4]'.", +) +@click.option( + "-l", + "--limit", + "limit_opt", + default=None, + type=int, + help="Top K (max results).", +) @click.pass_obj -def search(obj): +def search(obj, collectionName_opt, annsField_opt, vector_json, limit_opt): """ Perform vector similarity search. USAGE: milvus_cli > search + milvus_cli > search -c my_coll -f embedding -v '[0.1,0.2,0.3,0.4]' -l 5 + + NON-INTERACTIVE: + Pass -c, -f, -v, and -l for dense vector search (indexed collection). INTERACTIVE PROMPTS: Collection name Target collection @@ -865,6 +956,90 @@ def search(obj): SEE ALSO: query, create index, show index """ + if ( + collectionName_opt + and annsField_opt + and vector_json is not None + and limit_opt is not None + ): + collectionName = collectionName_opt + annsField = annsField_opt + limit = limit_opt + try: + vector = json.loads(vector_json.strip()) + if not isinstance(vector, list): + raise ParameterException("Dense vector must be a JSON array") + data = [vector] + + fields_info = obj.collection.list_fields_info(collectionName) + annsField_info = next( + (field for field in fields_info if field["name"] == annsField), None + ) + if not annsField_info: + click.echo( + f"Field {annsField} not found in collection {collectionName}.", + err=True, + ) + return + + indexes = obj.index.list_indexes(collectionName, onlyData=True) + indexDetails = None + for index in indexes: + if index.get("field_name") == annsField: + indexDetails = index + break + + hasIndex = bool(indexDetails) + if indexDetails: + index_type = indexDetails.get("index_type", "AUTOINDEX") + metricType = indexDetails.get("metric_type", "") + search_parameters = IndexTypesMap.get(index_type, {}).get( + "search_parameters", [] + ) + params = [] + defaults = { + "nprobe": "10", + "ef": "64", + "search_list": "20", + "reorder_k": "10", + "search_length": "10", + "search_k": "100", + "drop_ratio_search": "0.2", + } + for parameter in search_parameters: + if parameter == "metric_type": + continue + pval = defaults.get(parameter, "0") + params.append(f"{parameter}:{pval}") + else: + metricType = "" + params = [] + + searchParameters = validateSearchParams( + data=data, + annsField=annsField, + metricType=metricType, + params=params, + limit=limit, + expr="", + outputFields="", + roundDecimal=-1, + hasIndex=hasIndex, + guarantee_timestamp=0, + partitionNames="", + ) + except ParameterException as pe: + click.echo("Error!\n{}".format(str(pe))) + return + except Exception as e: + click.echo("Error!\n{}".format(str(e)), err=True) + return + else: + results = obj.data.search(collectionName, searchParameters) + click.echo("Search result: \n") + click.echo(results) + return + collectionName = click.prompt( "Collection name", type=click.Choice(obj.collection.list_collections()) ) diff --git a/milvus_cli/scripts/index_client_cli.py b/milvus_cli/scripts/index_client_cli.py index e9befd2..69199e0 100644 --- a/milvus_cli/scripts/index_client_cli.py +++ b/milvus_cli/scripts/index_client_cli.py @@ -11,13 +11,66 @@ @create.command("index") +@click.option( + "-c", + "--collection", + "collectionName", + default=None, + help="Collection name (use with -f, -t, -m for non-interactive create)", +) +@click.option( + "-f", + "--field", + "fieldName", + default=None, + help="Vector field name", +) +@click.option( + "-in", + "--index-name", + "indexName", + default=None, + help="Index name (defaults to field name)", +) +@click.option( + "-t", + "--index-type", + "indexType", + default=None, + help="Index type (e.g. FLAT, IVF_FLAT, HNSW)", +) +@click.option( + "-m", + "--metric", + "metricType", + default=None, + help="Metric type (e.g. L2, IP, COSINE)", +) +@click.option( + "--param", + "param_pairs", + multiple=True, + help="Index build parameter as key:value (repeatable). Example: --param nlist:1024", +) @click.pass_obj -def createIndex(obj): +def createIndex( + obj, + collectionName, + fieldName, + indexName, + indexType, + metricType, + param_pairs, +): """ Create an index on a vector field. USAGE: milvus_cli > create index + milvus_cli > create index -c COL -f vec -t FLAT -m L2 + + NON-INTERACTIVE: + When -c, -f, -t, and -m are all set, prompts are skipped. INTERACTIVE PROMPTS: Collection name Target collection @@ -55,6 +108,35 @@ def createIndex(obj): SEE ALSO: list indexes, show index, delete index, show index_progress """ + non_interactive = bool( + collectionName and fieldName and indexType and metricType + ) + if non_interactive: + try: + if collectionName not in obj.collection.list_collections(): + raise Exception(f"Collection '{collectionName}' does not exist") + fields = obj.collection.list_field_names(collectionName) + if fieldName not in fields: + raise Exception( + f"Field '{fieldName}' not found in collection '{collectionName}'" + ) + idx_name = indexName or fieldName + params_list = list(param_pairs) + click.echo( + obj.index.create_index( + collectionName, + fieldName, + idx_name, + indexType, + metricType, + params_list, + ) + ) + click.echo("Create index successfully!") + except Exception as e: + click.echo("Error!\n{}".format(str(e))) + return + try: collectionName = click.prompt( "Collection name", type=click.Choice(obj.collection.list_collections()) @@ -164,21 +246,29 @@ def show_index_details(obj, collectionName, indexName): type=str, ) @click.option("-in", "--index-name", "indexName", help="Index name") +@click.option( + "--yes", + "-y", + is_flag=True, + help="Skip confirmation prompt", +) @click.pass_obj -def delete_index(obj, collectionName, indexName): +def delete_index(obj, collectionName, indexName, yes): """ Delete index. Example: milvus_cli > delete index -c test_collection -in index_name + milvus_cli > delete index -c test_collection -in embedding --yes """ - click.echo( - "Warning!\nYou are trying to delete the index of collection. This action cannot be undone!\n" - ) - if not click.confirm("Do you want to continue?"): - return + if not yes: + click.echo( + "Warning!\nYou are trying to delete the index of collection. This action cannot be undone!\n" + ) + if not click.confirm("Do you want to continue?"): + return try: click.echo(obj.index.drop_index(collectionName, indexName)) except Exception as e: diff --git a/milvus_cli/scripts/user_client_cli.py b/milvus_cli/scripts/user_client_cli.py index d3a3564..df327f1 100644 --- a/milvus_cli/scripts/user_client_cli.py +++ b/milvus_cli/scripts/user_client_cli.py @@ -42,20 +42,28 @@ def list_users(obj): @delete.command("user") @click.option("-u", "--username", "username", help="The username of milvus user.") +@click.option( + "--yes", + "-y", + is_flag=True, + help="Skip confirmation prompt", +) @click.pass_obj -def deleteUser(obj, username): +def deleteUser(obj, username, yes=False): """ Drop user in milvus by username Example: milvus_cli > delete user -u zilliz + milvus_cli > delete user -u zilliz --yes """ - click.echo( - "Warning!\nYou are trying to delete the user in milvus. This action cannot be undone!\n" - ) - if not click.confirm("Do you want to continue?"): - return + if not yes: + click.echo( + "Warning!\nYou are trying to delete the user in milvus. This action cannot be undone!\n" + ) + if not click.confirm("Do you want to continue?"): + return try: result = obj.user.delete_user( username, diff --git a/milvus_cli/utils.py b/milvus_cli/utils.py index d9ad9ca..8182177 100644 --- a/milvus_cli/utils.py +++ b/milvus_cli/utils.py @@ -212,6 +212,24 @@ def _get_partitions(self, collection_name): except Exception: return [] + def _collection_from_trailing_args(self, args): + """Return collection name following -c/--collection in token list.""" + for i, tok in enumerate(args): + if tok in ("-c", "--collection", "--collection-name") and i + 1 < len( + args + ): + return args[i + 1] or None + return None + + def _get_field_names(self, collection_name): + """Get field names for a collection (e.g. create index -f).""" + if not collection_name or self.milvus_cli_obj is None: + return [] + try: + return self.milvus_cli_obj.collection.list_field_names(collection_name) + except Exception: + return [] + def createCompleteFuncs(self, cmdDict): for cmd in cmdDict: sub_cmds = cmdDict[cmd] @@ -268,6 +286,14 @@ def f_complete(args): return [d for d in databases if d.startswith(current_arg)] return databases + # Field name after create index -c COL -f + if prev_arg in ["-f", "--field"]: + coll = self._collection_from_trailing_args(args) + fields = self._get_field_names(coll) if coll else [] + if current_arg: + return [f for f in fields if f.startswith(current_arg)] + return fields + return self._complete_path(args[-1]) return f_complete diff --git a/tests/test_data.py b/tests/test_data.py index 8d78b43..902a926 100644 --- a/tests/test_data.py +++ b/tests/test_data.py @@ -1,8 +1,10 @@ """Integration tests for data operation commands.""" -import pytest +import csv import json import os +import pytest + class TestData: """Test data operation commands.""" @@ -16,8 +18,8 @@ def loaded_collection(self, run_connected, unique_name): "auto_id": False, "fields": [ {"name": "id", "type": "INT64", "is_primary": True}, - {"name": "embedding", "type": "FLOAT_VECTOR", "dim": 4} - ] + {"name": "embedding", "type": "FLOAT_VECTOR", "dim": 4}, + ], } schema_file = f"/tmp/{coll_name}_schema.json" with open(schema_file, "w") as f: @@ -32,7 +34,10 @@ def loaded_collection(self, run_connected, unique_name): pytest.skip(f"Failed to create collection: {output}") # Create index and load - run_connected(f"create index -c {coll_name} -f embedding -t FLAT -m L2") + output, code = run_connected( + f"create index -c {coll_name} -f embedding -t FLAT -m L2" + ) + assert code == 0, output run_connected(f"load collection -c {coll_name}") yield coll_name @@ -44,58 +49,46 @@ def loaded_collection(self, run_connected, unique_name): except OSError: pass - @pytest.mark.skip(reason="create index command is interactive only - fixture cannot create index") def test_insert_and_query(self, loaded_collection, run_connected): - """Test insert and query commands. - - Note: This test is skipped because the loaded_collection fixture - requires creating an index, but the 'create index' command only - supports interactive input, not command-line options. - """ + """Test insert and query commands.""" coll = loaded_collection - # Insert data using file - data = [ - {"id": 1, "embedding": [0.1, 0.2, 0.3, 0.4]}, - {"id": 2, "embedding": [0.5, 0.6, 0.7, 0.8]} - ] - data_file = f"/tmp/{coll}_data.json" - with open(data_file, "w") as f: - json.dump(data, f) + # insert file expects a positional path to .csv (cells JSON-parsed per Fs.formatRowForData) + data_file = f"/tmp/{coll}_data.csv" + with open(data_file, "w", newline="") as f: + w = csv.writer(f) + w.writerow(["id", "embedding"]) + w.writerow([1, json.dumps([0.1, 0.2, 0.3, 0.4])]) + w.writerow([2, json.dumps([0.5, 0.6, 0.7, 0.8])]) - output, code = run_connected(f"insert file -c {coll} --data-file {data_file}") - assert code == 0 + output, code = run_connected(f"insert file -c {coll} {data_file}") + assert code == 0, output # Flush to make data visible run_connected(f"flush -c {coll}") - # Query - output, code = run_connected(f"query collection -c {coll} -f 'id >= 0'") - assert code == 0 + # Query (top-level `query`, not `query collection`) + output, code = run_connected(f"query -c {coll} -e 'id >= 0'") + assert code == 0, output os.remove(data_file) - @pytest.mark.skip(reason="create index command is interactive only - fixture cannot create index") def test_delete_entities(self, loaded_collection, run_connected): - """Test delete entities command. - - Note: This test is skipped because the loaded_collection fixture - requires creating an index, but the 'create index' command only - supports interactive input, not command-line options. - """ + """Test delete entities command.""" coll = loaded_collection - # Insert data first - data = [{"id": 100, "embedding": [0.1, 0.2, 0.3, 0.4]}] - data_file = f"/tmp/{coll}_del_data.json" - with open(data_file, "w") as f: - json.dump(data, f) + data_file = f"/tmp/{coll}_del_data.csv" + with open(data_file, "w", newline="") as f: + w = csv.writer(f) + w.writerow(["id", "embedding"]) + w.writerow([100, json.dumps([0.1, 0.2, 0.3, 0.4])]) - run_connected(f"insert file -c {coll} --data-file {data_file}") + run_connected(f"insert file -c {coll} {data_file}") run_connected(f"flush -c {coll}") - # Delete - output, code = run_connected(f"delete entity -c {coll} -f 'id == 100'") - assert code == 0 + output, code = run_connected( + f"delete entities -c {coll} -e 'id == 100' --yes" + ) + assert code == 0, output os.remove(data_file) diff --git a/tests/test_index.py b/tests/test_index.py index df58eb8..145787f 100644 --- a/tests/test_index.py +++ b/tests/test_index.py @@ -39,13 +39,8 @@ def test_collection_for_index(self, run_connected, unique_name): except OSError: pass - @pytest.mark.skip(reason="create index command is interactive only - no CLI options") def test_create_and_delete_index(self, test_collection_for_index, run_connected): - """Test create and delete index. - - Note: This test is skipped because the 'create index' command - only supports interactive input, not command-line options. - """ + """Test create and delete index (non-interactive flags).""" coll = test_collection_for_index # Create index @@ -57,24 +52,19 @@ def test_create_and_delete_index(self, test_collection_for_index, run_connected) assert code == 0 # Delete index - output, code = run_connected(f"delete index -c {coll} -f embedding") + output, code = run_connected(f"delete index -c {coll} -in embedding --yes") assert code == 0 - @pytest.mark.skip(reason="create index command is interactive only - no CLI options") def test_show_index(self, test_collection_for_index, run_connected): - """Test show index command. - - Note: This test is skipped because the 'create index' command - only supports interactive input, not command-line options. - """ + """Test show index command.""" coll = test_collection_for_index # Create index first run_connected(f"create index -c {coll} -f embedding -t FLAT -m L2") - # Show index - output, code = run_connected(f"show index -c {coll} -f embedding") + # Show index (MilvusClient indexes by field / index name) + output, code = run_connected(f"show index -c {coll} -in embedding") assert code == 0 # Cleanup - run_connected(f"delete index -c {coll} -f embedding") + run_connected(f"delete index -c {coll} -in embedding --yes") diff --git a/tests/test_partition.py b/tests/test_partition.py index 0fb6607..7176ba0 100644 --- a/tests/test_partition.py +++ b/tests/test_partition.py @@ -72,16 +72,15 @@ def test_show_partition_stats(self, test_collection_for_partition, run_connected output, code = run_connected(f"show partition_stats -c {test_collection_for_partition} -p _default") assert code == 0 - @pytest.mark.skip(reason="Loading partition requires an index, and create index command is interactive only") def test_load_and_release_partition(self, test_collection_for_partition, run_connected): - """Test load and release partition. - - Note: This test is skipped because loading a partition requires - an index, and the 'create index' command only supports interactive - input, not command-line options. - """ + """Test load and release partition (requires vector index).""" coll = test_collection_for_partition + output, code = run_connected( + f"create index -c {coll} -f embedding -t FLAT -m L2" + ) + assert code == 0 + # Load output, code = run_connected(f"load partition -c {coll} -p _default") assert code == 0 diff --git a/tests/test_search_query.py b/tests/test_search_query.py index 2e82258..8576a7c 100644 --- a/tests/test_search_query.py +++ b/tests/test_search_query.py @@ -1,8 +1,10 @@ """Integration tests for search and query commands.""" -import pytest +import csv import json import os +import pytest + class TestSearchQuery: """Test search and query commands.""" @@ -16,8 +18,8 @@ def searchable_collection(self, run_connected, unique_name): "auto_id": False, "fields": [ {"name": "id", "type": "INT64", "is_primary": True}, - {"name": "embedding", "type": "FLOAT_VECTOR", "dim": 4} - ] + {"name": "embedding", "type": "FLOAT_VECTOR", "dim": 4}, + ], } schema_file = f"/tmp/{coll_name}_schema.json" with open(schema_file, "w") as f: @@ -32,18 +34,27 @@ def searchable_collection(self, run_connected, unique_name): pytest.skip(f"Failed to create collection: {output}") # Create index - run_connected(f"create index -c {coll_name} -f embedding -t FLAT -m L2") + output, code = run_connected( + f"create index -c {coll_name} -f embedding -t FLAT -m L2" + ) + assert code == 0, output - # Insert data - data = [ - {"id": i, "embedding": [float(i)*0.1, float(i)*0.2, float(i)*0.3, float(i)*0.4]} - for i in range(10) - ] - data_file = f"/tmp/{coll_name}_data.json" - with open(data_file, "w") as f: - json.dump(data, f) + # Insert data (CSV for insert file) + data_file = f"/tmp/{coll_name}_data.csv" + with open(data_file, "w", newline="") as f: + w = csv.writer(f) + w.writerow(["id", "embedding"]) + for i in range(10): + vec = [ + float(i) * 0.1, + float(i) * 0.2, + float(i) * 0.3, + float(i) * 0.4, + ] + w.writerow([i, json.dumps(vec)]) - run_connected(f"insert file -c {coll_name} --data-file {data_file}") + output, code = run_connected(f"insert file -c {coll_name} {data_file}") + assert code == 0, output run_connected(f"flush -c {coll_name}") run_connected(f"load collection -c {coll_name}") @@ -57,30 +68,18 @@ def searchable_collection(self, run_connected, unique_name): except OSError: pass - @pytest.mark.skip(reason="create index command is interactive only - fixture cannot create index") def test_search(self, searchable_collection, run_connected): - """Test search command. - - Note: This test is skipped because the searchable_collection fixture - requires creating an index, but the 'create index' command only - supports interactive input, not command-line options. - """ + """Test search command (non-interactive flags).""" coll = searchable_collection output, code = run_connected( - f"search collection -c {coll} -f embedding -v '[0.1, 0.2, 0.3, 0.4]' -l 5" + f'search -c {coll} -f embedding -v "[0.1, 0.2, 0.3, 0.4]" -l 5' ) - assert code == 0 + assert code == 0, output - @pytest.mark.skip(reason="create index command is interactive only - fixture cannot create index") def test_query(self, searchable_collection, run_connected): - """Test query command. - - Note: This test is skipped because the searchable_collection fixture - requires creating an index, but the 'create index' command only - supports interactive input, not command-line options. - """ + """Test query command (non-interactive flags).""" coll = searchable_collection - output, code = run_connected(f"query collection -c {coll} -f 'id < 5'") - assert code == 0 + output, code = run_connected(f"query -c {coll} -e 'id < 5'") + assert code == 0, output diff --git a/tests/test_user_role.py b/tests/test_user_role.py index 230c1ff..853c92f 100644 --- a/tests/test_user_role.py +++ b/tests/test_user_role.py @@ -23,8 +23,8 @@ def test_create_and_delete_user(self, run_connected, unique_name): output, code = run_connected("list users") assert username in output - # Delete user - output, code = run_connected(f"delete user -u {username}") + # Delete user (--yes: non-interactive CI / pytest CliRunner) + output, code = run_connected(f"delete user -u {username} --yes") assert code == 0 def test_list_roles(self, run_connected):