Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 6 additions & 5 deletions flowbio/cli/_samples.py
Original file line number Diff line number Diff line change
Expand Up @@ -271,10 +271,11 @@ def _configure_import(import_parser: argparse.ArgumentParser) -> None:


def _job_id(value: str) -> SampleImportJobId:
try:
return SampleImportJobId(int(value))
except ValueError:
raise argparse.ArgumentTypeError(f"job id must be an integer, got {value!r}") from None
# Ids are strings on the wire but always digits; a non-numeric one would
# fail deep inside the server's query building rather than at the boundary.
if not (value.isascii() and value.isdigit()):
raise argparse.ArgumentTypeError(f"job id must be an integer, got {value!r}")
return SampleImportJobId(value)


def _configure_import_status(import_status: argparse.ArgumentParser) -> None:
Expand Down Expand Up @@ -666,7 +667,7 @@ def _import_status_command(

def _job_summary(job: SampleImportJob) -> str:
if job.status == "COMPLETED":
ids = ", ".join(str(sample_id) for sample_id in job.sample_ids) or "none"
ids = ", ".join(job.sample_ids) or "none"
suffix = _timestamp_suffix("finished", job.finished)
return f"Job {job.id}: COMPLETED{suffix}. Sample ids: {ids}."
if job.status == "FAILED":
Expand Down
19 changes: 13 additions & 6 deletions flowbio/v2/samples.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@
from pathlib import Path
from typing import TYPE_CHECKING, Literal, NewType

from pydantic import BaseModel, Field, field_validator
from pydantic import BaseModel, ConfigDict, Field, field_validator

from flowbio.v2._pagination import PageIterator
from flowbio.v2.exceptions import (
Expand Down Expand Up @@ -147,9 +147,10 @@ class MultiplexedUpload(BaseModel, frozen=True):
)


SampleImportJobId = NewType("SampleImportJobId", int)
SampleImportJobId = NewType("SampleImportJobId", str)
"""The identifier of a sample-import job, as returned by
:meth:`SampleResource.import_samples`."""
:meth:`SampleResource.import_samples`. A bare integer written as a string —
the API serialises record ids as strings so browsers do not round them."""


SampleImportStatus = Literal["RUNNING", "COMPLETED", "FAILED"]
Expand Down Expand Up @@ -198,7 +199,7 @@ class SampleImportSpec:
metadata: dict[str, str] | None = None


class SampleImportJob(BaseModel, frozen=True):
class SampleImportJob(BaseModel):
"""A batch job that imports one or more accessions into samples.

All accessions submitted in one :meth:`SampleResource.import_samples` call
Expand All @@ -207,6 +208,12 @@ class SampleImportJob(BaseModel, frozen=True):
``"COMPLETED"``.
"""

# coerce_numbers_to_str: a server that hasn't shipped string ids yet
# (an on-prem install caught between upgrades) sends these as integers,
# and pydantic does not coerce int -> str in lax mode by default. Python
# ints are unbounded, so accepting one here loses no precision.
model_config = ConfigDict(frozen=True, coerce_numbers_to_str=True)

id: SampleImportJobId = Field(description="Unique identifier for this import job.")
status: SampleImportStatus = Field(description="The job's current lifecycle state.")
created: datetime | None = Field(default=None, description="When the job was created.")
Expand All @@ -217,11 +224,11 @@ class SampleImportJob(BaseModel, frozen=True):
accessions: list[str] = Field(
default_factory=list, description="The accessions submitted with this job, in submission order.",
)
sample_ids: list[int] = Field(
sample_ids: list[str] = Field(
default_factory=list,
description="The created samples' ids, corresponding to ``accessions`` once the job has completed.",
)
execution_id: int | None = Field(
execution_id: str | None = Field(
default=None, description="The pipeline execution backing this job, if one was created.",
)
error: str | None = Field(
Expand Down
2 changes: 1 addition & 1 deletion setup.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@

setup(
name="flowbio",
version="0.11.1",
version="0.12.0",
description="A client for the Flow API.",
long_description=long_description,
long_description_content_type="text/markdown",
Expand Down
4 changes: 2 additions & 2 deletions source/cli.rst
Original file line number Diff line number Diff line change
Expand Up @@ -460,7 +460,7 @@ otherwise the standard mapping above.
Started import job 42 for 2 accession(s) (status: RUNNING). Check progress with 'flowbio samples import-status --job-id 42'.

$ flowbio samples import --sheet ./accessions.csv --json
{"id": 42, "status": "RUNNING", "created": "2024-04-05T19:34:38Z", "started": null, "finished": null, "accessions": ["ERR1160845", "ERR10677146"], "sample_ids": [], "execution_id": null, "error": null}
{"id": "42", "status": "RUNNING", "created": "2024-04-05T19:34:38Z", "started": null, "finished": null, "accessions": ["ERR1160845", "ERR10677146"], "sample_ids": [], "execution_id": null, "error": null}

``samples import-status``
~~~~~~~~~~~~~~~~~~~~~~~~~
Expand Down Expand Up @@ -517,7 +517,7 @@ mapping above.
Job 42: COMPLETED (finished 2024-04-05 19:38:20 UTC). Sample ids: 101, 102.

$ flowbio samples import-status --job-id 42 --json
{"id": 42, "status": "COMPLETED", "created": "2024-04-05T19:34:38Z", "started": "2024-04-05T19:34:40Z", "finished": "2024-04-05T19:38:20Z", "accessions": ["ERR1160845", "ERR10677146"], "sample_ids": [101, 102], "execution_id": 7, "error": null}
{"id": "42", "status": "COMPLETED", "created": "2024-04-05T19:34:38Z", "started": "2024-04-05T19:34:40Z", "finished": "2024-04-05T19:38:20Z", "accessions": ["ERR1160845", "ERR10677146"], "sample_ids": ["101", "102"], "execution_id": "7", "error": null}

``api get``
~~~~~~~~~~~
Expand Down
37 changes: 23 additions & 14 deletions tests/unit/cli/test_samples.py
Original file line number Diff line number Diff line change
Expand Up @@ -953,14 +953,14 @@ def _job_json(
execution_id: int | None = 7,
) -> dict:
return {
"id": job_id,
"id": str(job_id),
"status": status,
"created": 1700000000,
"started": 1700000001 if status != "RUNNING" else None,
"finished": 1700000002 if status in ("COMPLETED", "FAILED") else None,
"accessions": accessions,
"sample_ids": sample_ids or [],
"execution_id": execution_id,
"sample_ids": [str(sample_id) for sample_id in sample_ids or []],
"execution_id": None if execution_id is None else str(execution_id),
"error": error,
}

Expand Down Expand Up @@ -1010,14 +1010,14 @@ def test_json_document_reports_job_fields(
document = json.loads(result.stdout)
assert result.stdout.count("\n") == 1
assert document == {
"id": 42,
"id": "42",
"status": "RUNNING",
"created": "2023-11-14T22:13:20Z",
"started": None,
"finished": None,
"accessions": ["ERR1"],
"sample_ids": [],
"execution_id": 7,
"execution_id": "7",
"error": None,
}

Expand Down Expand Up @@ -1391,7 +1391,7 @@ def test_reports_running_job(self, run_cli) -> None:
def test_running_job_with_started_reports_when_it_started(self, run_cli) -> None:
respx.get(f"{SAMPLE_IMPORTS_URL}/42").mock(
return_value=httpx.Response(HTTPStatus.OK, json={
"id": 42, "status": "RUNNING", "created": 1700000000,
"id": "42", "status": "RUNNING", "created": 1700000000,
"started": 1700000001, "finished": None,
"accessions": ["ERR1"], "sample_ids": [], "execution_id": None, "error": None,
}),
Expand All @@ -1408,7 +1408,7 @@ def test_running_job_with_started_reports_when_it_started(self, run_cli) -> None
def test_started_with_non_utc_offset_is_reported_in_utc(self, run_cli) -> None:
respx.get(f"{SAMPLE_IMPORTS_URL}/42").mock(
return_value=httpx.Response(HTTPStatus.OK, json={
"id": 42, "status": "RUNNING", "created": 1700000000,
"id": "42", "status": "RUNNING", "created": 1700000000,
"started": "2024-04-05T19:34:38+02:00", "finished": None,
"accessions": ["ERR1"], "sample_ids": [], "execution_id": None, "error": None,
}),
Expand All @@ -1425,7 +1425,7 @@ def test_started_with_non_utc_offset_is_reported_in_utc(self, run_cli) -> None:
def test_started_with_no_offset_is_treated_as_utc(self, run_cli) -> None:
respx.get(f"{SAMPLE_IMPORTS_URL}/42").mock(
return_value=httpx.Response(HTTPStatus.OK, json={
"id": 42, "status": "RUNNING", "created": 1700000000,
"id": "42", "status": "RUNNING", "created": 1700000000,
"started": "2024-04-05T19:34:38", "finished": None,
"accessions": ["ERR1"], "sample_ids": [], "execution_id": None, "error": None,
}),
Expand All @@ -1442,7 +1442,7 @@ def test_started_with_no_offset_is_treated_as_utc(self, run_cli) -> None:
def test_naive_started_is_reported_as_utc_in_json_too(self, run_cli) -> None:
respx.get(f"{SAMPLE_IMPORTS_URL}/42").mock(
return_value=httpx.Response(HTTPStatus.OK, json={
"id": 42, "status": "RUNNING", "created": 1700000000,
"id": "42", "status": "RUNNING", "created": 1700000000,
"started": "2024-04-05T19:34:38", "finished": None,
"accessions": ["ERR1"], "sample_ids": [], "execution_id": None, "error": None,
}),
Expand All @@ -1460,7 +1460,7 @@ def test_naive_started_is_reported_as_utc_in_json_too(self, run_cli) -> None:
def test_non_utc_offset_started_is_reported_as_utc_in_json_too(self, run_cli) -> None:
respx.get(f"{SAMPLE_IMPORTS_URL}/42").mock(
return_value=httpx.Response(HTTPStatus.OK, json={
"id": 42, "status": "RUNNING", "created": 1700000000,
"id": "42", "status": "RUNNING", "created": 1700000000,
"started": "2024-04-05T19:34:38+02:00", "finished": None,
"accessions": ["ERR1"], "sample_ids": [], "execution_id": None, "error": None,
}),
Expand All @@ -1478,7 +1478,7 @@ def test_non_utc_offset_started_is_reported_as_utc_in_json_too(self, run_cli) ->
def test_running_job_falls_back_to_created_when_not_started(self, run_cli) -> None:
respx.get(f"{SAMPLE_IMPORTS_URL}/42").mock(
return_value=httpx.Response(HTTPStatus.OK, json={
"id": 42, "status": "RUNNING", "created": 1700000000,
"id": "42", "status": "RUNNING", "created": 1700000000,
"started": None, "finished": None,
"accessions": ["ERR1"], "sample_ids": [], "execution_id": None, "error": None,
}),
Expand Down Expand Up @@ -1565,6 +1565,15 @@ def test_non_numeric_job_id_reports_clear_message(self, run_cli) -> None:
assert "_job_id" not in result.stderr
assert "job id" in result.stderr.lower()

def test_non_ascii_digit_job_id_reports_clear_message(self, run_cli) -> None:
result = run_cli(
"--token", TOKEN, "samples", "import-status", "--job-id", "٤٢",
)

assert result.exit_code == 2
assert "_job_id" not in result.stderr
assert "job id" in result.stderr.lower()

@respx.mock
def test_completed_job_with_no_sample_ids_reports_none(self, run_cli) -> None:
respx.get(f"{SAMPLE_IMPORTS_URL}/42").mock(
Expand Down Expand Up @@ -1596,14 +1605,14 @@ def test_json_document_matches_job_shape(self, run_cli) -> None:
document = json.loads(result.stdout)
assert result.stdout.count("\n") == 1
assert document == {
"id": 42,
"id": "42",
"status": "COMPLETED",
"created": "2023-11-14T22:13:20Z",
"started": "2023-11-14T22:13:21Z",
"finished": "2023-11-14T22:13:22Z",
"accessions": ["ERR1"],
"sample_ids": [101],
"execution_id": 7,
"sample_ids": ["101"],
"execution_id": "7",
"error": None,
}

Expand Down
Loading
Loading