Skip to content

Commit f50eb5c

Browse files
committed
Improve handling of semicolons in --command
As copilot review pointed out and was verified by reading the bcftools manual, there are bcftools view commands that CAN take ;, often within its own quotes. This adds support for such commands, with the caveat that the inner quotes need to be escaped on the CLI side. example CLI command that can be used with tests/fixtures/vcf_specification_v45_example11.vcf.gz divbase-cli query vcf --samples "NA00001,NA00002,NA00003" \ --command "view -i 'FILTER~\"q10\"'"
1 parent b060712 commit f50eb5c

11 files changed

Lines changed: 234 additions & 35 deletions

File tree

docs/user-guides/vcf-query-syntax.md

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -267,6 +267,22 @@ The DivBase server will check if these are included in the `--command` string be
267267
# • Pipe segment 3, token '-W': Option '-W/--write-index' is handled by the DivBase server.
268268
```
269269

270+
### 4.3. Special considerations
271+
272+
#### 4.3.1. Semicolons inside `FILTER` expressions
273+
274+
As described above in e.g. [Section 4](#4-writing-the-bcftools-command-argument), users can create pipes of several `bcftools` commands by delimiting them with a semicolon (`;`). However, a few `bcftools` commands can possibly contain semicolons as part of a value. One example is `bcftools view -i` [filter expressions](https://samtools.github.io/bcftools/bcftools.html#expressions), such as `bcftools view -i FILTER="q10;s50"`. If you want to use this for DivBase queries, you will need to ensure that the inner double quotes are escaped in the `--command` string so the expression is preserved correctly through CLI/API parsing.
275+
276+
Example with escaped inner double quotes:
277+
278+
```bash
279+
divbase-cli query vcf \
280+
--all-samples \
281+
--command "view -i 'FILTER=\"q10;s50\"'; view -r 20:1-2000000"
282+
```
283+
284+
Failing to not escape the inner double quotes might lead to the string given by `--command` being altered befored it reaches the `bcftools` layer of DivBase, and can thus lead to incorrect results or errors in the query.
285+
270286
## 5. What happens after submitting a VCF query? (Job lifecycle and outputs)
271287

272288
### 5.1. What the user can see after submitting the job

packages/divbase-api/src/divbase_api/services/queries.py

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@
3333
TaskUserError,
3434
)
3535
from divbase_lib.metadata_validator import SharedMetadataValidator, ValidationCategory
36+
from divbase_lib.utils import split_semicolon_bcftools_command_segments
3637

3738
logger = logging.getLogger(__name__)
3839

@@ -121,7 +122,7 @@ def _parse_command_segments(command: str) -> list[ParsedCommandSegment]:
121122
"""
122123
segments: list[ParsedCommandSegment] = []
123124

124-
for position, raw_cmd in enumerate(command.split(";"), start=1):
125+
for position, raw_cmd in enumerate(split_semicolon_bcftools_command_segments(command), start=1):
125126
cmd = raw_cmd.strip()
126127
if not cmd:
127128
# Defensive skip only. Empty segments are rejected by schema validation upstream.
@@ -461,7 +462,7 @@ def validate_user_submitted_bcftools_command(command: str, all_samples: bool = F
461462
cmd_name = segment.cmd_name
462463

463464
if not cmd:
464-
# Empty command/empty ';' segments are already rejected by Pydantic (NonEmptyCommand) upstream of this function. This skip is only defensive.
465+
# Empty command/empty ';' segments are already rejected by upstream Pydantic command field validation.
465466
continue
466467

467468
if cmd_name not in valid_commands:
@@ -662,7 +663,7 @@ def build_commands_config(
662663

663664
if not command or command.strip() == ";" or command.strip() == "":
664665
raise BcftoolsPipeEmptyCommandError()
665-
command_list = command.split(";")
666+
command_list = split_semicolon_bcftools_command_segments(command)
666667
pipe_has_sample_placeholder = any(
667668
self._command_has_sample_placeholder(cmd.strip()) for cmd in command_list if cmd.strip()
668669
)

packages/divbase-lib/src/divbase_lib/api_schemas/queries.py

Lines changed: 18 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,11 @@
22
Schemas for query routes.
33
"""
44

5-
from typing import Annotated, Optional, Self
5+
from typing import Optional, Self
66

7-
from pydantic import AfterValidator, BaseModel, ConfigDict, model_validator
7+
from pydantic import BaseModel, ConfigDict, field_validator, model_validator
8+
9+
from divbase_lib.utils import split_semicolon_bcftools_command_segments
810

911
# Shared helper functions and models
1012

@@ -14,8 +16,7 @@ def validate_command_not_empty(command: str) -> str:
1416
Validator function to ensure that the user-submitted bcftools command string is not empty or just whitespace.
1517
If there are multiple segments separated by semicolons, it also validates that none of the segments are empty or whitespace.
1618
17-
This is shared by the BcftoolsQueryRequest.command field and the BcftoolsQueryKwargs.command fields, and is used instead of @field_validator()
18-
to avoid replicating the code.
19+
Shared helper for command field validators in BcftoolsQueryRequest.command and BcftoolsQueryKwargs.command.
1920
"""
2021
command_string = command.strip()
2122
if command_string == "":
@@ -24,7 +25,7 @@ def validate_command_not_empty(command: str) -> str:
2425
'If you only want to subset based on samples, use --command "view -s" in combination with one of the sample-selection options: --tsv-filter, --samples, --samples-file.'
2526
)
2627

27-
segments = command_string.split(";")
28+
segments = split_semicolon_bcftools_command_segments(command_string)
2829
for position, segment in enumerate(segments, start=1):
2930
if segment.strip() == "":
3031
raise ValueError(
@@ -35,9 +36,6 @@ def validate_command_not_empty(command: str) -> str:
3536
return command
3637

3738

38-
NonEmptyCommand = Annotated[str, AfterValidator(validate_command_not_empty)]
39-
40-
4139
class SharedBaseModel(BaseModel):
4240
"""Shared pydantic BaseModel for VCF query response and kwarg schemas."""
4341

@@ -65,10 +63,15 @@ class BcftoolsQueryRequest(SharedBaseModel):
6563

6664
tsv_filter: str | None = None # Used for metadata mode only
6765
metadata_tsv_name: str | None = None # Used for metadata mode only
68-
command: NonEmptyCommand # TODO add field to describe that this is bcftools commands
66+
command: str # bcftools command input with --command
6967
samples: list[str] | None = None
7068
all_samples: bool = False
7169

70+
@field_validator("command")
71+
@classmethod
72+
def validate_command(_cls, value: str) -> str:
73+
return validate_command_not_empty(value)
74+
7275
@model_validator(mode="after")
7376
def validate_sample_selection_mode(self) -> Self:
7477
tsv_filter = self.tsv_filter
@@ -117,7 +120,7 @@ class BcftoolsQueryKwargs(SharedBaseModel):
117120

118121
tsv_filter: str | None = None # Used for metadata mode only
119122
metadata_tsv_name: str | None = None # Used for metadata mode only
120-
command: NonEmptyCommand
123+
command: str # bcftools command input with --command
121124
bucket_name: str
122125
project_id: int
123126
project_name: str
@@ -126,6 +129,11 @@ class BcftoolsQueryKwargs(SharedBaseModel):
126129
samples: list[str] | None = None
127130
all_samples: bool = False
128131

132+
@field_validator("command")
133+
@classmethod
134+
def validate_command(_cls, value: str) -> str:
135+
return validate_command_not_empty(value)
136+
129137
@model_validator(mode="after")
130138
def validate_sample_selection_mode(self) -> Self:
131139
tsv_filter = self.tsv_filter

packages/divbase-lib/src/divbase_lib/utils.py

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,8 @@
1+
from typing import Literal, TypeAlias
2+
3+
QuoteChar: TypeAlias = Literal["'", '"']
4+
5+
16
def format_file_size(size_bytes: int | float | None, decimals: int = 2) -> str:
27
"""
38
Converts a file size in bytes to a human-readable format.
@@ -15,3 +20,57 @@ def format_file_size(size_bytes: int | float | None, decimals: int = 2) -> str:
1520
size_bytes /= power
1621
n += 1
1722
return f"{size_bytes:.{decimals}f} {power_labels[n]}B"
23+
24+
25+
def split_semicolon_bcftools_command_segments(command: str) -> list[str]:
26+
"""
27+
Split a user provided bcftools command string on semicolons while respecting quoted substrings.
28+
29+
bcftools view allows semicolons for certain options (e.g. -i 'FILTER="A;B"'), so we need to ensure that we only
30+
split on semicolons that are not inside quotes.
31+
32+
--command "view -i 'FILTER=\"A;B\"'; view -r 1:1-1000"
33+
34+
Slides character by character through the command string to identify the true semicolon delimiters, whilst ignoring semicolons inside quotes.
35+
The command strings are not that long, so we can afford to do this on a character level.
36+
"""
37+
command_segments: list[str] = []
38+
segment_chars: list[str] = []
39+
40+
single_quote = "'"
41+
double_quote = '"'
42+
43+
# Possible states None = outside quotes, "'" = inside single-quoted substring, '"' = inside double-quoted substring.
44+
open_quote_char: QuoteChar | None = None
45+
is_escaped = False
46+
47+
for char in command:
48+
if is_escaped:
49+
segment_chars.append(char)
50+
is_escaped = False
51+
continue
52+
53+
# Handle backslash-escaped characters in the received command text.
54+
# In single-quoted regions, backslashes are treated as literal chars.
55+
if char == "\\" and open_quote_char != single_quote:
56+
segment_chars.append(char)
57+
is_escaped = True
58+
continue
59+
60+
if char in (double_quote, single_quote):
61+
segment_chars.append(char)
62+
if open_quote_char is None:
63+
open_quote_char = char
64+
elif open_quote_char == char:
65+
open_quote_char = None
66+
continue
67+
68+
if char == ";" and open_quote_char is None:
69+
command_segments.append("".join(segment_chars))
70+
segment_chars = []
71+
continue
72+
73+
segment_chars.append(char)
74+
75+
command_segments.append("".join(segment_chars))
76+
return command_segments

tests/e2e_integration/cli_commands/conftest.py

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
import logging
1111
from pathlib import Path
1212

13+
import boto3
1314
import pytest
1415
from typer.testing import CliRunner
1516

@@ -164,3 +165,28 @@ def factory(CONSTANTS):
164165
def fixtures_dir():
165166
"""Path to the fixtures directory."""
166167
return Path(__file__).parent.parent.parent / "fixtures"
168+
169+
170+
@pytest.fixture
171+
def cleaned_project_bucket(CONSTANTS):
172+
"""
173+
Ensure cleaned-project bucket is empty before and after test.
174+
175+
Use this for tests that require deterministic project bucket contents.
176+
"""
177+
s3_resource = boto3.resource(
178+
"s3",
179+
endpoint_url=CONSTANTS["MINIO_URL"],
180+
aws_access_key_id=CONSTANTS["BAD_ACCESS_KEY"],
181+
aws_secret_access_key=CONSTANTS["BAD_SECRET_KEY"],
182+
)
183+
184+
project_name = CONSTANTS["CLEANED_PROJECT"]
185+
bucket_name = CONSTANTS["PROJECT_TO_BUCKET_MAP"][project_name]
186+
# pylance does not understand boto3 resource return types.
187+
bucket = s3_resource.Bucket(bucket_name) # type: ignore
188+
bucket.object_versions.delete()
189+
190+
yield project_name, bucket_name
191+
192+
bucket.object_versions.delete()

tests/e2e_integration/cli_commands/test_file_cli.py

Lines changed: 1 addition & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,6 @@
1212
from io import StringIO
1313
from pathlib import Path
1414

15-
import boto3
1615
import pytest
1716
from typer.testing import CliRunner
1817

@@ -38,7 +37,7 @@
3837

3938

4039
@pytest.fixture(autouse=True)
41-
def start_with_clean_project(CONSTANTS):
40+
def start_with_clean_project(cleaned_project_bucket):
4241
"""
4342
For tests that require a project with a clean bucket, this fixture will
4443
ensure that the CONSTANTS["CLEANED_PROJECT"]'s bucket is empty before and after running the test.
@@ -47,23 +46,8 @@ def start_with_clean_project(CONSTANTS):
4746
If you modify the approach make sure your implementation does not just add delete markers.
4847
The files need to be actually deleted.
4948
"""
50-
s3_resource = boto3.resource(
51-
"s3",
52-
endpoint_url=CONSTANTS["MINIO_URL"],
53-
aws_access_key_id=CONSTANTS["BAD_ACCESS_KEY"],
54-
aws_secret_access_key=CONSTANTS["BAD_SECRET_KEY"],
55-
)
56-
# pylance does not understand boto3 resource returns types, hence ignore below
57-
cleaned_project_bucket_name = CONSTANTS["PROJECT_TO_BUCKET_MAP"][CONSTANTS["CLEANED_PROJECT"]]
58-
59-
bucket = s3_resource.Bucket(cleaned_project_bucket_name) # type: ignore
60-
bucket.object_versions.delete()
61-
6249
yield
6350

64-
bucket = s3_resource.Bucket(cleaned_project_bucket_name) # type: ignore
65-
bucket.object_versions.delete()
66-
6751

6852
@pytest.fixture(scope="module")
6953
def large_file_for_multipart(tmp_path_factory):

tests/e2e_integration/cli_commands/test_query_cli.py

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
"""
88

99
import datetime
10+
import gzip
1011
import logging
1112
import os
1213
import subprocess
@@ -197,6 +198,86 @@ def test_bcftools_pipe_query(
197198
)
198199

199200

201+
@pytest.mark.parametrize(
202+
"arg_command,expected_records,expected_view_command_filter_fragment",
203+
[
204+
(
205+
r"view -i 'FILTER~\"q10\"'",
206+
[("17330", ".", "q10"), ("1234567", "microsat1", "q10;s50")],
207+
'FILTER~"q10"',
208+
),
209+
(
210+
r"view -i 'FILTER=\"q10\"'",
211+
[("17330", ".", "q10")],
212+
'FILTER="q10"',
213+
),
214+
(
215+
r"view -i 'FILTER=\"q10;s50\"'",
216+
[("1234567", "microsat1", "q10;s50")],
217+
'FILTER="q10;s50"',
218+
),
219+
],
220+
ids=[
221+
"filter-subset-match-q10",
222+
"filter-exact-match-q10",
223+
"filter-exact-match-q10-s50",
224+
],
225+
)
226+
def test_bcftools_pipe_query_supports_semicolon_in_filter_expression_e2e(
227+
CONSTANTS,
228+
logged_in_edit_user_with_existing_config,
229+
run_update_dimensions,
230+
project_map,
231+
fixtures_dir,
232+
cleaned_project_bucket,
233+
arg_command,
234+
expected_records,
235+
expected_view_command_filter_fragment,
236+
):
237+
"""
238+
Test that VCF queries can support bcftools view -i FILTER expressions (including semicolon values)
239+
"""
240+
project_name, bucket_name = cleaned_project_bucket
241+
project_id = project_map[project_name]
242+
user_id = 1
243+
fixture_name = "vcf_specification_v45_example11.vcf.gz"
244+
fixture_path = (fixtures_dir / fixture_name).resolve()
245+
upload_result = runner.invoke(app, f"files upload {fixture_path} --project {project_name}")
246+
assert upload_result.exit_code == 0, f"Upload failed: {upload_result.stdout}"
247+
248+
run_update_dimensions(bucket_name=bucket_name, project_id=project_id, project_name=project_name, user_id=user_id)
249+
250+
query_result = runner.invoke(
251+
app,
252+
f'query vcf --samples "NA00001,NA00002,NA00003" --command "{arg_command}" --project {project_name}',
253+
)
254+
assert query_result.exit_code == 0, f"Query submission failed: {query_result.stdout}"
255+
assert "Job submitted" in query_result.stdout
256+
257+
user_task_id = query_result.stdout.strip().split()[-1]
258+
task_result = wait_for_task_complete(user_task_id=user_task_id)
259+
assert task_result.status == "SUCCESS", f"Task failed: {task_result.result}"
260+
assert hasattr(task_result.result, "output_file"), f"Missing output_file in task result: {task_result.result}"
261+
output_file = task_result.result.output_file
262+
263+
stream_result = runner.invoke(
264+
app,
265+
f"files stream {output_file} --project {project_name}",
266+
)
267+
assert stream_result.exit_code == 0, f"Streaming query result failed: {stream_result.stdout}"
268+
269+
streamed_vcf_content = gzip.decompress(stream_result.stdout_bytes).decode("utf-8")
270+
assert "##bcftools_viewCommand=" in streamed_vcf_content
271+
assert expected_view_command_filter_fragment in streamed_vcf_content
272+
273+
records = [line for line in streamed_vcf_content.splitlines() if line and not line.startswith("#")]
274+
parsed_records = [(cols[1], cols[2], cols[6]) for cols in (record.split("\t") for record in records)]
275+
276+
assert parsed_records == expected_records, (
277+
f"Unexpected records for command '{arg_command}'. Expected {expected_records}, got {parsed_records}"
278+
)
279+
280+
200281
def test_bcftools_pipe_query_direct_samples_mode(
201282
CONSTANTS,
202283
logged_in_edit_user_with_existing_config,
882 Bytes
Binary file not shown.

tests/unit/divbase_api/test_vcf_queries.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,7 @@ class TestValidateUserSubmittedBcftoolsCommand:
5252
"view -s",
5353
"view -s -r 21:15000000-25000000",
5454
"view --samples -i 'QUAL>20'",
55+
"view -i 'FILTER=\"A;B\"'; view -r 1:1-1000",
5556
],
5657
)
5758
def test_validate_user_submitted_bcftools_command_accepts_valid_commands(self, command):

0 commit comments

Comments
 (0)