divbase-lib 0.1.0.dev3__tar.gz → 0.1.0.dev5__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (19) hide show
  1. {divbase_lib-0.1.0.dev3 → divbase_lib-0.1.0.dev5}/.gitignore +5 -1
  2. {divbase_lib-0.1.0.dev3 → divbase_lib-0.1.0.dev5}/PKG-INFO +6 -5
  3. {divbase_lib-0.1.0.dev3 → divbase_lib-0.1.0.dev5}/pyproject.toml +5 -4
  4. divbase_lib-0.1.0.dev5/src/divbase_lib/__init__.py +1 -0
  5. divbase_lib-0.1.0.dev5/src/divbase_lib/api_schemas/announcements.py +18 -0
  6. {divbase_lib-0.1.0.dev3 → divbase_lib-0.1.0.dev5}/src/divbase_lib/api_schemas/project_versions.py +26 -1
  7. {divbase_lib-0.1.0.dev3 → divbase_lib-0.1.0.dev5}/src/divbase_lib/api_schemas/queries.py +1 -0
  8. {divbase_lib-0.1.0.dev3 → divbase_lib-0.1.0.dev5}/src/divbase_lib/api_schemas/s3.py +8 -0
  9. {divbase_lib-0.1.0.dev3 → divbase_lib-0.1.0.dev5}/src/divbase_lib/api_schemas/vcf_dimensions.py +12 -0
  10. {divbase_lib-0.1.0.dev3 → divbase_lib-0.1.0.dev5}/src/divbase_lib/divbase_constants.py +8 -0
  11. {divbase_lib-0.1.0.dev3 → divbase_lib-0.1.0.dev5}/src/divbase_lib/exceptions.py +35 -0
  12. divbase_lib-0.1.0.dev5/src/divbase_lib/metadata_validator.py +827 -0
  13. divbase_lib-0.1.0.dev5/src/divbase_lib/utils.py +17 -0
  14. divbase_lib-0.1.0.dev3/src/divbase_lib/__init__.py +0 -1
  15. {divbase_lib-0.1.0.dev3 → divbase_lib-0.1.0.dev5}/README.md +0 -0
  16. {divbase_lib-0.1.0.dev3 → divbase_lib-0.1.0.dev5}/src/divbase_lib/api_schemas/__init__.py +0 -0
  17. {divbase_lib-0.1.0.dev3 → divbase_lib-0.1.0.dev5}/src/divbase_lib/api_schemas/auth.py +0 -0
  18. {divbase_lib-0.1.0.dev3 → divbase_lib-0.1.0.dev5}/src/divbase_lib/api_schemas/task_history.py +0 -0
  19. {divbase_lib-0.1.0.dev3 → divbase_lib-0.1.0.dev5}/src/divbase_lib/s3_checksums.py +0 -0
@@ -13,7 +13,7 @@ __pycache__/
13
13
 
14
14
  # project specific files
15
15
  /sample_metadata.tsv
16
- sample_metadata_*.tsv
16
+ /sample_metadata_*.tsv
17
17
  *.vcf
18
18
  *.vcf.gz
19
19
  *.vcf.gz.csi
@@ -21,6 +21,7 @@ sample_metadata_*.tsv
21
21
  !tests/fixtures/*.vcf.gz
22
22
  tests/fixtures/temp*
23
23
  tests/fixtures/merged*
24
+ divbase_metadata_template*.tsv
24
25
 
25
26
  # query job config files
26
27
  bcftools_divbase_job_config.json
@@ -38,5 +39,8 @@ scripts/benchmarking/results
38
39
 
39
40
  # mkdocs build cache
40
41
  .cache/
42
+ # mkdocs auto generated files
43
+ docs/cli/_auto_generated/*.md
44
+
41
45
  # pypi
42
46
  dist/
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: divbase-lib
3
- Version: 0.1.0.dev3
3
+ Version: 0.1.0.dev5
4
4
  Summary: Library module for Divbase
5
5
  Project-URL: Homepage, https://divbase.scilifelab.se
6
6
  Project-URL: Documentation, https://scilifelabdatacentre.github.io/divbase
@@ -18,10 +18,11 @@ Classifier: Programming Language :: Python :: 3.12
18
18
  Classifier: Programming Language :: Python :: 3.13
19
19
  Classifier: Programming Language :: Python :: 3.14
20
20
  Requires-Python: >=3.12
21
- Requires-Dist: boto3<2,>=1.42.27
22
- Requires-Dist: pandas<3,>=2.3.3
23
- Requires-Dist: pydantic<3,>=2.12.5
24
- Requires-Dist: pyyaml<7,>=6.0.3
21
+ Requires-Dist: httpx==0.28.1
22
+ Requires-Dist: pandas==2.3.3
23
+ Requires-Dist: pydantic==2.12.5
24
+ Requires-Dist: pyyaml==6.0.3
25
+ Requires-Dist: stamina==25.2.0
25
26
  Description-Content-Type: text/markdown
26
27
 
27
28
  # divbase-lib
@@ -8,10 +8,11 @@ authors = [
8
8
  ]
9
9
  requires-python = ">=3.12"
10
10
  dependencies = [
11
- "pyyaml>=6.0.3,<7",
12
- "boto3>=1.42.27,<2",
13
- "pandas>=2.3.3,<3",
14
- "pydantic>=2.12.5,<3"
11
+ "pyyaml==6.0.3",
12
+ "pandas==2.3.3", # TODO - consider major version bump
13
+ "pydantic==2.12.5",
14
+ "httpx==0.28.1",
15
+ "stamina==25.2.0"
15
16
  ]
16
17
  dynamic = ["version"]
17
18
 
@@ -0,0 +1 @@
1
+ __version__ = "0.1.0.dev5"
@@ -0,0 +1,18 @@
1
+ """
2
+ Schemas for announcements.
3
+ """
4
+
5
+ from pydantic import BaseModel, ConfigDict, Field
6
+
7
+
8
+ class AnnouncementResponse(BaseModel):
9
+ """Response model for an announcement returned by the API."""
10
+
11
+ heading: str = Field(..., description="Title of the announcement.")
12
+ message: str | None = Field(None, description="Detailed message of the announcement.")
13
+ level: str = Field(
14
+ ...,
15
+ description="The announcement level, which can control styling of announcement. Possible values are: info, success, warning, danger.",
16
+ )
17
+
18
+ model_config = ConfigDict(from_attributes=True)
@@ -4,6 +4,8 @@ Schemas for project versioning routes.
4
4
  Project versions are the state of all files in a project's storage bucket at a given time point.
5
5
  """
6
6
 
7
+ from typing import TypedDict
8
+
7
9
  from pydantic import BaseModel, Field
8
10
 
9
11
 
@@ -22,6 +24,12 @@ class DeleteVersionRequest(BaseModel):
22
24
  version_name: str = Field(..., description="Name of the version to delete")
23
25
 
24
26
 
27
+ class UpdateVersionRequest(BaseModel):
28
+ version_name: str = Field(..., description="Name of the version to update")
29
+ new_name: str | None = Field(None, description="New name for the version")
30
+ new_description: str | None = Field(None, description="New description for the version")
31
+
32
+
25
33
  # Response Models
26
34
  class ProjectBasicInfo(BaseModel):
27
35
  """Base model for describing a single project version, not for direct use in an endpoint."""
@@ -43,12 +51,23 @@ class ProjectVersionInfo(ProjectBasicInfo):
43
51
  is_deleted: bool = Field(..., description="Whether this version has been soft-deleted")
44
52
 
45
53
 
54
+ # Typed dict used as this is used in the db model to define the contents of the JSONB
55
+ class FileDetails(TypedDict):
56
+ """Details about a single file in a project version, ETag (is checksum) and size is in bytes."""
57
+
58
+ version_id: str
59
+ etag: str
60
+ size: int
61
+
62
+
46
63
  class ProjectVersionDetailResponse(ProjectBasicInfo):
47
64
  """Full information about a single project version, including the files at that version."""
48
65
 
49
66
  created_at: str = Field(..., description="ISO timestamp when version was created")
50
67
  is_deleted: bool = Field(..., description="Whether this version has been soft-deleted")
51
- files: dict[str, str] = Field(..., description="Mapping of file names to their version IDs")
68
+ files: dict[str, FileDetails] = Field(
69
+ ..., description="Info about all files at this version, including: version IDs, etags and file sizes"
70
+ )
52
71
 
53
72
 
54
73
  class DeleteVersionResponse(BaseModel):
@@ -60,3 +79,9 @@ class DeleteVersionResponse(BaseModel):
60
79
  description="Whether the version was already soft-deleted before this request",
61
80
  )
62
81
  date_deleted: str = Field(..., description="ISO timestamp of when the version was soft-deleted")
82
+
83
+
84
+ class UpdateVersionResponse(ProjectBasicInfo):
85
+ """Response model for updating a version."""
86
+
87
+ created_at: str = Field(..., description="ISO timestamp when version was created")
@@ -55,6 +55,7 @@ class SampleMetadataQueryTaskResult(BaseModel):
55
55
  unique_sample_ids: list[str]
56
56
  unique_filenames: list[str]
57
57
  query_message: str
58
+ warnings: list[str] = []
58
59
  status: Optional[str] = None
59
60
 
60
61
 
@@ -46,6 +46,14 @@ class ListObjectsResponse(BaseModel):
46
46
  )
47
47
 
48
48
 
49
+ ## list soft-deleted objects models ##
50
+ class SoftDeletedObjectDetails(BaseModel):
51
+ """Details about a single soft-deleted object in an S3 bucket."""
52
+
53
+ name: str = Field(..., description="The name of the object in the bucket.")
54
+ last_modified: datetime = Field(..., description="The date and time the object was deleted.")
55
+
56
+
49
57
  ## file info models ##
50
58
  class ObjectVersionInfo(BaseModel):
51
59
  """Detailed information about a single version of an S3 object."""
@@ -40,3 +40,15 @@ class DimensionsShowResult(BaseModel):
40
40
  vcf_files: list[dict]
41
41
  skipped_file_count: int
42
42
  skipped_files: list[dict]
43
+
44
+
45
+ class DimensionsSamplesResult(BaseModel):
46
+ """Result model for showing unique samples across project VCFs."""
47
+
48
+ unique_samples: list[str] # Already sorted, by the CRUD function get_unique_samples_by_project_async()
49
+
50
+
51
+ class DimensionsScaffoldsResult(BaseModel):
52
+ """Result model for showing unique scaffolds across project VCFs."""
53
+
54
+ unique_scaffolds: list[str] # Already sorted, by the CRUD function get_unique_scaffolds_by_project_async()
@@ -42,4 +42,12 @@ UNSUPPORTED_CHARACTERS_IN_FILENAMES = (":", "*", "?", "<", ">", "|", "\\")
42
42
  # This prefix is used for all *.vcf.gz results files from a query job/task.
43
43
  # After the prefix comes the job id which is a rolling integer.
44
44
  # E.g. format: result_of_job_<job-id>.vcf.gz , where <job-id> = 1 and is auto-incremented for every new job.
45
+ # NOTE: If you update this, you should also update the S3 lifecycle policies associated with this.
45
46
  QUERY_RESULTS_FILE_PREFIX = "result_of_job_"
47
+
48
+ # CLI includes this in every request to the server,
49
+ # and server uses it to determine if it should:
50
+ # 1. Reject the request as the version is too outdated
51
+ # 2. Add an announcement about a new CLI version being available when the user logs in.
52
+ # 3. Do Nothing as the user's CLI version is up to date.
53
+ CLI_VERSION_HEADER_KEY = "X-CLI-Version"
@@ -101,12 +101,47 @@ class SidecarColumnNotFoundError(Exception):
101
101
  pass
102
102
 
103
103
 
104
+ class SidecarSampleIDError(Exception):
105
+ """Raised when a Sample_ID is invalid or duplicated when loading sidecar data."""
106
+
107
+ pass
108
+
109
+
110
+ class SidecarMetadataFormatError(Exception):
111
+ """Raised when the sidecar metadata TSV file has formatting issues (duplicate columns, empty columns, commas in values, etc.)."""
112
+
113
+ pass
114
+
115
+
116
+ class TaskUserError(Exception):
117
+ """
118
+ Raised in Celery tasks when an error needs to propagate back to the CLI user.
119
+
120
+ This is intentionally kept as a simple Exception subclass (no custom __init__)
121
+ to avoid UnpicklableExceptionWrapper when passing through Celery's JSON serialization/deserialization.
122
+ Complex exception types such as those inheriting from DivBaseAPIException seem to trigger UnpicklableExceptionWrapper.
123
+
124
+ This class is essentially a wrapper to allow to use more complex exceptions in Celery tasks and catch them
125
+ in the API route handlers to return user-friendly error messages to the CLI.
126
+ In the Celery task use it like:
127
+ raise TaskUserError(str(SomeComplexError(...))) from None
128
+ """
129
+
130
+ pass
131
+
132
+
104
133
  class NoVCFFilesFoundError(Exception):
105
134
  """Raised when no VCF files are found in the project bucket."""
106
135
 
107
136
  pass
108
137
 
109
138
 
139
+ class DimensionsNotUpToDateWithBucketError(Exception):
140
+ """Raised when VCF files in a bucket are missing or outdated in the dimensions index."""
141
+
142
+ pass
143
+
144
+
110
145
  class ChecksumVerificationError(Exception):
111
146
  """Raised when a calculated file's checksum does not match the expected value."""
112
147
 
@@ -0,0 +1,827 @@
1
+ """
2
+ Shared validation logic for DivBase sidecar metadata TSV files.
3
+
4
+ This file contains the single source of truth for the TSV content validation logic used by both
5
+ the CLI validator (ClientSideMetadataTSVValidator) on the client-side, and the SidecarQueryManager on the server-side.
6
+
7
+ Note! Logic for the queries themselves (e.g. how filtering is handled) is not shared between the two.
8
+ This file is only for validation of the contents of the TSV file, not for query processing.
9
+ """
10
+
11
+ import ast
12
+ import csv
13
+ from collections import defaultdict
14
+ from dataclasses import dataclass, field
15
+ from enum import Enum
16
+ from pathlib import Path
17
+ from typing import Any
18
+
19
+ import pandas as pd
20
+
21
+
22
+ class ValidationCategory(Enum):
23
+ """
24
+ Categories for validation messages to be used by the sever-side query engine in SidecarQueryManager.
25
+ These categories are used by SidecarQueryManager to act on the validation results by Enums instead of string-matching the error and warning messages.
26
+ """
27
+
28
+ # Error categories
29
+ FILE_READ = "file_read"
30
+ HEADER = "header"
31
+ SAMPLE_ID_COLUMN = "sample_id_column"
32
+ SAMPLE_ID_VALUE = "sample_id_value"
33
+ ROW_FORMAT = "row_format"
34
+ LIST_SYNTAX = "list_syntax"
35
+ MIXED_TYPE = "mixed_type"
36
+ DIMENSIONS = "dimensions"
37
+ # Warning categories
38
+ FORMAT = "format"
39
+ TYPE_CLASSIFICATION = "type_classification"
40
+
41
+
42
+ @dataclass
43
+ class ValidationMessage:
44
+ """A class to store the category and message for errors and warnings detected by SharedMetadataValidator."""
45
+
46
+ category: ValidationCategory
47
+ message: str
48
+
49
+
50
+ @dataclass
51
+ class ValidationStats:
52
+ """A class to store any relevant statistics collected during validation. Used on the CLI side."""
53
+
54
+ total_columns: int = 0
55
+ user_defined_columns: int = 0
56
+ samples_in_tsv: int = 0
57
+ samples_matching_project: int = 0
58
+ total_project_samples: int = 0
59
+ numeric_column_count: int = 0
60
+ string_column_count: int = 0
61
+ mixed_type_column_count: int = 0
62
+ empty_cells_per_column: dict[str, int] = field(default_factory=dict)
63
+ has_multi_values: bool = False
64
+
65
+
66
+ @dataclass
67
+ class MetadataValidationResult:
68
+ """
69
+ Dataclass to hold the results of the TSV file validation. Used by the callers of SharedMetadataValidator
70
+ (including the client-side CLI validator and the server-side query engine).
71
+ """
72
+
73
+ errors: list[ValidationMessage] = field(default_factory=list)
74
+ warnings: list[ValidationMessage] = field(default_factory=list)
75
+ stats: ValidationStats = field(default_factory=ValidationStats)
76
+ df: pd.DataFrame | None = None
77
+ mixed_type_columns: list[str] = field(default_factory=list)
78
+ numeric_columns: list[str] = field(default_factory=list)
79
+ string_columns: list[str] = field(default_factory=list)
80
+
81
+
82
+ class SharedMetadataValidator:
83
+ """
84
+ Core validation logic for DivBase sidecar metadata TSV files. Shared between client-side ClientSideMetadataTSVValidator
85
+ and server-side SidecarQueryManager to ensure consistent validation behavior.
86
+
87
+ It does not validate metadata query filters. That is handled in the SidecarQueryManager.
88
+
89
+ This class handles the following validation of the TSV content:
90
+ - Header (duplicates, empty columns, first column name)
91
+ - Sample_ID (empty, duplicates, no lists)
92
+ - Column type (numeric vs string, list-type multi-value cells)
93
+ - Data format (commas, semicolons, whitespace, column count)
94
+ - List syntax validation and mixed type detection
95
+
96
+ IMPORTANT! This class never raises errors, it collects them. This is to allow the output of the class to be compatible
97
+ with the CLI validator on the client-side and the query engine (SidecarQueryManager) on the server side. The CLI validator
98
+ is designed to present all errors and warnings to the user in a single pass in terminal display so that they can address all of them.
99
+ The sever-side use the errors and warnings collected by this class to raise expections on the first error it encounters in order to
100
+ protect the query engine from malformed TSV content.
101
+ """
102
+
103
+ def __init__(
104
+ self,
105
+ file_path: Path,
106
+ project_samples: set[str] | None = None,
107
+ skip_dimensions_check: bool = False,
108
+ dimensions_sample_preview_limit: int | None = 20,
109
+ ):
110
+ self.file_path = file_path
111
+ self.project_samples = project_samples
112
+ self.tsv_samples: set[str] = set()
113
+ self.skip_dimensions_check = skip_dimensions_check
114
+ self.dimensions_sample_preview_limit = dimensions_sample_preview_limit
115
+ self._cells_with_hard_errors: set[tuple[int, str]] = set()
116
+ self.result = MetadataValidationResult()
117
+
118
+ def load_and_validate(self) -> MetadataValidationResult:
119
+ """
120
+ Main entry point to the class. Load a TSV file and call helper methods to validate it.
121
+
122
+ After loading the TSV into a pandas DataFrame, cells that look like Python
123
+ list literals (starting with '[') are parsed with ast.literal_eval so that
124
+ downstream validation and querying can work with native Python lists and
125
+ their correctly-inferred element types.
126
+ """
127
+ # Pre-pandas checks:
128
+ try:
129
+ with open(self.file_path, "r", newline="", encoding="utf-8") as f:
130
+ reader = csv.reader(f, delimiter="\t")
131
+ rows = list(reader)
132
+ except Exception as e:
133
+ self.result.errors.append(
134
+ ValidationMessage(ValidationCategory.FILE_READ, f"Failed to read file (TSV read stage): {e}")
135
+ )
136
+ return self.result
137
+
138
+ if not rows:
139
+ self.result.errors.append(ValidationMessage(ValidationCategory.FILE_READ, "File is empty"))
140
+ return self.result
141
+
142
+ first_line = "\t".join(rows[0])
143
+ header_errors, header_warnings = self._validate_raw_header(first_line)
144
+ self.result.errors.extend(header_errors)
145
+ self.result.warnings.extend(header_warnings)
146
+
147
+ if len(rows) > 1:
148
+ row_errors, row_warnings = self._check_row_formatting(rows)
149
+ self.result.errors.extend(row_errors)
150
+ self.result.warnings.extend(row_warnings)
151
+
152
+ # Initiate Pandas dataframe from TSV and check for parsing issues:
153
+ try:
154
+ df = pd.read_csv(self.file_path, sep="\t", skipinitialspace=True, on_bad_lines="skip")
155
+ df.columns = df.columns.str.lstrip("#")
156
+ self._strip_whitespace_from_cells(df)
157
+ except Exception as e:
158
+ self.result.errors.append(
159
+ ValidationMessage(ValidationCategory.FILE_READ, f"Failed to read file (pandas parse stage): {e}")
160
+ )
161
+ return self.result
162
+
163
+ # Dataframe checks:
164
+ try:
165
+ list_syntax_errors, cells_with_hard_errors = self._parse_list_cells_in_dataframe(df)
166
+ self.result.errors.extend(list_syntax_errors)
167
+ self._cells_with_hard_errors = cells_with_hard_errors
168
+ self.result.df = df
169
+
170
+ semicolon_warnings = self._check_for_semicolons_in_plain_string_cells(df)
171
+ self.result.warnings.extend(semicolon_warnings)
172
+
173
+ comma_warnings = self._check_for_commas_in_plain_string_cells(df)
174
+ self.result.warnings.extend(comma_warnings)
175
+
176
+ sample_id_errors, sample_id_warnings = self._validate_sample_ids(df)
177
+ self.result.errors.extend(sample_id_errors)
178
+ self.result.warnings.extend(sample_id_warnings)
179
+
180
+ numeric_cols, string_cols, mixed_type_cols, cell_errors, cell_warnings = self._classify_column_type(df)
181
+ self.result.errors.extend(cell_errors)
182
+ self.result.warnings.extend(cell_warnings)
183
+ self.result.mixed_type_columns = mixed_type_cols
184
+ self.result.numeric_columns = numeric_cols
185
+ self.result.string_columns = string_cols
186
+
187
+ self.tsv_samples = self._extract_sample_ids_for_dimensions(df)
188
+
189
+ if not self.skip_dimensions_check and self.project_samples is not None:
190
+ dim_errors, dim_warnings = self._validate_dimensions_match(self.tsv_samples, self.project_samples)
191
+ self.result.errors.extend(dim_errors)
192
+ self.result.warnings.extend(dim_warnings)
193
+ except Exception as e:
194
+ self.result.errors.append(ValidationMessage(ValidationCategory.FILE_READ, f"Validation failed: {e}"))
195
+
196
+ self.result.stats = self._collect_statistics(df)
197
+
198
+ return self.result
199
+
200
+ def _strip_whitespace_from_cells(self, df: pd.DataFrame) -> None:
201
+ """Strip leading/trailing whitespace from all string cells in the DataFrame."""
202
+ for col in df.columns:
203
+ df[col] = df[col].apply(lambda x: x.strip() if isinstance(x, str) else x)
204
+
205
+ def _parse_list_cells_in_dataframe(self, df: pd.DataFrame) -> tuple[list[ValidationMessage], set[tuple[int, str]]]:
206
+ """
207
+ Parse all string cells in object columns that look like Python list literals, and collect errors for cells that fail to parse.
208
+
209
+ Only columns with dtype "object" are considered since numeric columns inferred by pandas cannot contain list strings.
210
+ Cells that start with '[' are parsed via ast.literal_eval. On success, the cell is replaced with the parsed Python list; on failure the cell is left as-is
211
+ and an error message is collected.
212
+
213
+ ast.literal_eval is whitespace-insensitive within list notation:
214
+ [3,2], [3, 2], and [ 3 , 2 ] all parse identically to [3, 2].
215
+ """
216
+ errors: list[ValidationMessage] = []
217
+ cells_with_hard_errors: set[tuple[int, str]] = set()
218
+ mixed_type_cells_by_column: defaultdict[str, list[tuple[int, Any]]] = defaultdict(
219
+ list
220
+ ) # Automatically initializes empty lists for new columns
221
+ list_syntax_errors_by_column: defaultdict[str, list[tuple[int, Any]]] = defaultdict(list)
222
+
223
+ # Detect errors on a cell basis
224
+ for col in df.select_dtypes(include=["object"]).columns:
225
+ for idx, cell_value in df[col].items():
226
+ if not isinstance(cell_value, str):
227
+ continue
228
+ stripped = cell_value.strip()
229
+ if not stripped.startswith("["):
230
+ continue
231
+ try:
232
+ parsed = ast.literal_eval(stripped)
233
+ if isinstance(parsed, list):
234
+ # Also reject lists that contain numeric and non-numeric types (e.g. [1, "two"]).
235
+ has_numeric = any(isinstance(element, (int, float)) for element in parsed)
236
+ has_non_numeric = any(not isinstance(element, (int, float)) for element in parsed)
237
+ if has_numeric and has_non_numeric:
238
+ mixed_type_cells_by_column[col].append((idx + 2, parsed))
239
+ cells_with_hard_errors.add((idx, col))
240
+ continue
241
+ df.at[idx, col] = parsed
242
+ else:
243
+ list_syntax_errors_by_column[col].append((idx + 2, cell_value))
244
+ cells_with_hard_errors.add((idx, col))
245
+ except (ValueError, SyntaxError):
246
+ list_syntax_errors_by_column[col].append((idx + 2, cell_value))
247
+ cells_with_hard_errors.add((idx, col))
248
+
249
+ # Collect cell value errors by column for brevity
250
+ for col, mixed_cells in mixed_type_cells_by_column.items():
251
+ preview_text, was_truncated = self._format_row_value_preview(
252
+ values=mixed_cells,
253
+ preview_limit=self.dimensions_sample_preview_limit,
254
+ )
255
+ full_output_hint = f" {self._build_full_sample_output_hint()}" if was_truncated else ""
256
+ errors.append(
257
+ ValidationMessage(
258
+ ValidationCategory.MIXED_TYPE,
259
+ f"Column '{col}': Found {len(mixed_cells)} cell(s) with mixed element types (both numeric and string values) in lists. "
260
+ 'All elements in a list must be the same type. Use either all numbers (e.g. [1, 2, 3]) or all strings (e.g. ["a", "b", "c"]). '
261
+ f"Affected cells: {preview_text}.{full_output_hint}",
262
+ )
263
+ )
264
+ for col, error_cells in list_syntax_errors_by_column.items():
265
+ preview_text, was_truncated = self._format_row_value_preview(
266
+ values=error_cells,
267
+ preview_limit=self.dimensions_sample_preview_limit,
268
+ )
269
+ full_output_hint = f" {self._build_full_sample_output_hint()}" if was_truncated else ""
270
+ errors.append(
271
+ ValidationMessage(
272
+ ValidationCategory.LIST_SYNTAX,
273
+ f"Column '{col}': Found {len(error_cells)} cell(s) with invalid list syntax or not parsed as list. "
274
+ 'Multi-value cells must use valid Python list notation, e.g. [1, 2, 3] or ["a", "b"]. '
275
+ f"Affected cells: {preview_text}.{full_output_hint}",
276
+ )
277
+ )
278
+
279
+ return errors, cells_with_hard_errors
280
+
281
+ def _validate_raw_header(self, header_line: str) -> tuple[list[ValidationMessage], list[ValidationMessage]]:
282
+ """
283
+ Validate the raw header line before pandas processing.
284
+
285
+ Attempts to catch issues that pandas would silently fix (like duplicate columns).
286
+ """
287
+ errors: list[ValidationMessage] = []
288
+
289
+ raw_columns = header_line.split("\t")
290
+ cleaned_columns = [col.lstrip("#") for col in raw_columns]
291
+
292
+ empty_columns = [i + 1 for i, col in enumerate(cleaned_columns) if not col.strip()]
293
+ if empty_columns:
294
+ errors.append(
295
+ ValidationMessage(
296
+ ValidationCategory.HEADER,
297
+ f"Empty column name(s) found at position(s): {empty_columns}. All columns must have a non-empty name.",
298
+ )
299
+ )
300
+
301
+ seen = {}
302
+ duplicate_columns = []
303
+ for col in cleaned_columns:
304
+ col_stripped = col.strip()
305
+ if col_stripped in seen:
306
+ if col_stripped not in duplicate_columns:
307
+ duplicate_columns.append(col_stripped)
308
+ else:
309
+ seen[col_stripped] = True
310
+
311
+ if duplicate_columns:
312
+ errors.append(
313
+ ValidationMessage(
314
+ ValidationCategory.HEADER,
315
+ f"Duplicate column names found: {duplicate_columns}. "
316
+ "Each column name must be unique in the metadata file.",
317
+ )
318
+ )
319
+
320
+ if raw_columns and raw_columns[0] != "#Sample_ID":
321
+ errors.append(
322
+ ValidationMessage(
323
+ ValidationCategory.SAMPLE_ID_COLUMN,
324
+ f"First column must be named '#Sample_ID', found: '{raw_columns[0]}'",
325
+ )
326
+ )
327
+
328
+ return errors, []
329
+
330
+ def _check_row_formatting(self, rows: list[list[str]]) -> tuple[list[ValidationMessage], list[ValidationMessage]]:
331
+ """
332
+ Check for row-level formatting issues that pandas might handle silently.
333
+ """
334
+ errors: list[ValidationMessage] = []
335
+ warnings: list[ValidationMessage] = []
336
+ whitespace_cells_by_column: defaultdict[str, list[tuple[int, str]]] = defaultdict(list)
337
+
338
+ header = rows[0]
339
+ num_columns = len(header)
340
+
341
+ for row_num, row in enumerate(rows[1:], start=2): # Start at row 2 (i.e. skip header)
342
+ # Report tab separation issues by row
343
+ if len(row) != num_columns:
344
+ sample_hint = f" (Sample_ID: '{row[0]}')" if row else ""
345
+ errors.append(
346
+ ValidationMessage(
347
+ ValidationCategory.ROW_FORMAT,
348
+ f"Row {row_num}: Expected {num_columns} tab-separated columns from reading the header, "
349
+ f"found {len(row)}{sample_hint}. "
350
+ "Check that all cells in the TSV are separated by tabs (not spaces).",
351
+ )
352
+ )
353
+ continue
354
+
355
+ for col_idx, cell in enumerate(row):
356
+ if cell != cell.strip():
357
+ col_name = header[col_idx]
358
+ whitespace_cells_by_column[col_name].append((row_num, cell))
359
+
360
+ for col_name, whitespace_cells in whitespace_cells_by_column.items():
361
+ preview_text, was_truncated = self._format_row_value_preview(
362
+ values=whitespace_cells, preview_limit=self.dimensions_sample_preview_limit
363
+ )
364
+ full_output_hint = f" {self._build_full_sample_output_hint()}" if was_truncated else ""
365
+ warnings.append(
366
+ ValidationMessage(
367
+ ValidationCategory.FORMAT,
368
+ f"Column '{col_name}': Found {len(whitespace_cells)} cell(s) with leading or trailing whitespace "
369
+ "(this is allowed, but note that they will be stripped by DivBase server when the TSV is used for queries). "
370
+ f"Affected cells: {preview_text}.{full_output_hint}",
371
+ )
372
+ )
373
+
374
+ return errors, warnings
375
+
376
+ def _validate_sample_ids(self, df: pd.DataFrame) -> tuple[list[ValidationMessage], list[ValidationMessage]]:
377
+ """
378
+ Validate Sample_ID column in the DataFrame.
379
+
380
+ The Sample_ID column must be present in the TSV, it has to be the first column, and it must contain non-empty, unique values.
381
+ Further more, it must have a single value per row. List values (Python list notation like ["S1", "S2"]) are not allowed in Sample_ID.
382
+ """
383
+ errors: list[ValidationMessage] = []
384
+ empty_sample_id_rows = []
385
+
386
+ if "Sample_ID" not in df.columns:
387
+ errors.append(
388
+ ValidationMessage(
389
+ ValidationCategory.SAMPLE_ID_COLUMN,
390
+ "The 'Sample_ID' column is required in the metadata file.",
391
+ )
392
+ )
393
+ return errors, []
394
+
395
+ for idx, val in df["Sample_ID"].items():
396
+ # Avoid pd.isna(list/array) ambiguity and treat only missing/empty scalar values as missing Sample_ID.
397
+ if val is None or (isinstance(val, str) and val == "") or (not isinstance(val, list) and pd.isna(val)):
398
+ empty_sample_id_rows.append(
399
+ (idx + 2, "<empty>")
400
+ ) # If val is used here, it will display pandas 'nan' to the user. <empty> is a clearer message
401
+ if empty_sample_id_rows:
402
+ preview_text, was_truncated = self._format_row_value_preview(
403
+ values=empty_sample_id_rows,
404
+ preview_limit=self.dimensions_sample_preview_limit,
405
+ )
406
+ full_output_hint = f" {self._build_full_sample_output_hint()}" if was_truncated else ""
407
+ errors.append(
408
+ ValidationMessage(
409
+ ValidationCategory.SAMPLE_ID_VALUE,
410
+ f"Sample_ID is empty or missing in {len(empty_sample_id_rows)} row(s). "
411
+ f"Affected rows: {preview_text}.{full_output_hint} All rows must have a valid Sample_ID.",
412
+ )
413
+ )
414
+
415
+ duplicate_rows_by_value: defaultdict[str, list[tuple[int, str]]] = defaultdict(list)
416
+ for idx, val in df["Sample_ID"].items():
417
+ if isinstance(val, list):
418
+ continue
419
+ if val is None or (isinstance(val, str) and val == "") or (not isinstance(val, list) and pd.isna(val)):
420
+ continue
421
+ sample_id_str = str(val)
422
+ duplicate_rows_by_value[sample_id_str].append((idx + 2, sample_id_str))
423
+
424
+ for sample_id, rows in duplicate_rows_by_value.items():
425
+ if len(rows) <= 1:
426
+ continue
427
+ preview_text, was_truncated = self._format_row_value_preview(
428
+ values=rows,
429
+ preview_limit=self.dimensions_sample_preview_limit,
430
+ )
431
+ full_output_hint = f" {self._build_full_sample_output_hint()}" if was_truncated else ""
432
+ errors.append(
433
+ ValidationMessage(
434
+ ValidationCategory.SAMPLE_ID_VALUE,
435
+ f"Duplicate Sample_IDs found: '{sample_id}' appears in {len(rows)} row(s). "
436
+ f"Affected rows: {preview_text}.{full_output_hint} Each Sample_ID must be unique.",
437
+ )
438
+ )
439
+
440
+ list_syntax_in_sample_ids = [
441
+ sid
442
+ for sid in df["Sample_ID"].dropna()
443
+ if isinstance(sid, list)
444
+ or (isinstance(sid, str) and sid.strip().startswith("[") and sid.strip().endswith("]"))
445
+ ]
446
+ if list_syntax_in_sample_ids:
447
+ errors.append(
448
+ ValidationMessage(
449
+ ValidationCategory.SAMPLE_ID_VALUE,
450
+ f"Sample_ID column contains list values: {list_syntax_in_sample_ids}. "
451
+ "Sample_ID must contain only one value per row (list notation is not allowed).",
452
+ )
453
+ )
454
+
455
+ return errors, []
456
+
457
+ def _extract_sample_ids_for_dimensions(self, df: pd.DataFrame) -> set[str]:
458
+ """
459
+ Extract sample IDs for dimensions matching in a way that avoid pandas type-related exceptions that can be cryptic to users.
460
+
461
+ Skips missing values and list-valued Sample_ID cells (e.g. ['test_duplicate', 'test_duplicate2']) which are handled by
462
+ Sample_ID validation errors elsewhere.
463
+ """
464
+ if "Sample_ID" not in df.columns:
465
+ return set()
466
+
467
+ sample_ids: set[str] = set()
468
+ for val in df["Sample_ID"].tolist():
469
+ if isinstance(val, list):
470
+ continue
471
+ if val is None or (isinstance(val, str) and val == "") or (not isinstance(val, list) and pd.isna(val)):
472
+ continue
473
+ sample_ids.add(str(val))
474
+ return sample_ids
475
+
476
+ def _classify_column_type(
477
+ self, df: pd.DataFrame
478
+ ) -> tuple[list[str], list[str], list[str], list[ValidationMessage], list[ValidationMessage]]:
479
+ """
480
+ Classify every user-defined column as numeric, string, or mixed-type in a single pass over the data.
481
+
482
+ For each column, every non-null cell is examined once:
483
+ - Single-value cells: numeric if ``float()`` succeeds, otherwise string.
484
+ - Multi-value cells (Python lists): numeric if all elements are int/float, string if all are strings.
485
+ A list with mixed element types (e.g. [1, "two"]) is a hard error because ast.literal_eval preserves the exact types
486
+ the user wrote, so mixed types indicate an explicit mistake.
487
+
488
+ After scanning all cells in a column, classify the column type:
489
+ - All cells are numeric -> numeric column
490
+ - All cells are string -> string column
491
+ - Contains both numeric and string cells -> mixed-type column: treat as string and send a single warning per column summarizing all type issues.
492
+ - All cells are Null -> numeric (to match Pandas default for NaN-only columns)
493
+ """
494
+ numeric_cols: list[str] = []
495
+ string_cols: list[str] = []
496
+ mixed_type_cols: list[str] = []
497
+ cell_errors: list[ValidationMessage] = []
498
+ cell_warnings: list[ValidationMessage] = []
499
+
500
+ for col in df.columns:
501
+ if col == "Sample_ID":
502
+ continue
503
+
504
+ series = df[col]
505
+ non_null_values = series.dropna()
506
+
507
+ if len(non_null_values) == 0:
508
+ numeric_cols.append(col)
509
+ continue
510
+
511
+ has_numeric = False
512
+ has_string = False
513
+ numeric_cells: list[tuple[int, Any]] = []
514
+ string_cells: list[tuple[int, Any]] = [] # true string cells only
515
+ mixed_type_cells: list[tuple[int, Any]] = []
516
+
517
+ for idx, cell_value in non_null_values.items():
518
+ if (idx, col) in self._cells_with_hard_errors:
519
+ # Do not classify cells that already have hard validation errors.
520
+ continue
521
+
522
+ if isinstance(cell_value, list):
523
+ cell_has_numeric = False
524
+ cell_has_string = False
525
+
526
+ for element in cell_value:
527
+ if isinstance(element, (int, float)):
528
+ cell_has_numeric = True
529
+ else:
530
+ cell_has_string = True
531
+
532
+ if cell_has_numeric and cell_has_string:
533
+ mixed_type_cells.append((idx + 2, cell_value))
534
+ has_string = True
535
+ # Do NOT add to string_cells; mixed-type lists are reported separately
536
+ elif cell_has_numeric:
537
+ has_numeric = True
538
+ numeric_cells.append((idx, cell_value))
539
+ else:
540
+ has_string = True
541
+ string_cells.append((idx, cell_value))
542
+ else:
543
+ try:
544
+ float(cell_value)
545
+ has_numeric = True
546
+ numeric_cells.append((idx, cell_value))
547
+ except (ValueError, TypeError):
548
+ has_string = True
549
+ string_cells.append((idx, cell_value))
550
+
551
+ # Report mixed-type warning per column. There are two cases: cells with different types, and multi-value cells that contain mixed types in the list.
552
+ if (has_numeric and has_string) or mixed_type_cells:
553
+ mixed_type_cols.append(col)
554
+
555
+ n_numeric = len(numeric_cells)
556
+ n_string = len(string_cells)
557
+ n_mixed = len(mixed_type_cells)
558
+
559
+ mixed_summary = f", {n_mixed} mixed-type list cells" if n_mixed > 0 else ""
560
+
561
+ summary = f"Column '{col}': This column contains mixed-type cells ({n_numeric} numeric, {n_string} string type cells{mixed_summary}), and will be treated as string type. Numeric query operations (ranges, inequalities) are not applicable to string columns."
562
+ message_parts = [summary]
563
+ hard_error_cells_in_column = sum(1 for _, error_col in self._cells_with_hard_errors if error_col == col)
564
+ if hard_error_cells_in_column > 0:
565
+ message_parts.append(
566
+ f"{hard_error_cells_in_column} cell(s) in this column have hard list-format/type errors and are reported separately under ERRORS."
567
+ )
568
+
569
+ # Only return the cell type that is in minority in the column. If equal, don't print this message
570
+ if n_numeric > 0 and n_string > 0 and n_numeric != n_string:
571
+ minority_cells = string_cells if n_string <= n_numeric else numeric_cells
572
+ minority_type = "string" if minority_cells is string_cells else "numeric"
573
+ preview_text, was_truncated = self._format_row_value_preview(
574
+ values=[(idx + 2, val) for idx, val in minority_cells],
575
+ preview_limit=self.dimensions_sample_preview_limit,
576
+ )
577
+ full_output_hint = f" {self._build_full_sample_output_hint()}" if was_truncated else ""
578
+ message_parts.append(
579
+ f"The {minority_type} cells are in minority and are: {preview_text}.{full_output_hint}"
580
+ )
581
+
582
+ # Always return cell values with mixed-type list cells
583
+ if n_mixed > 0:
584
+ preview_text, was_truncated = self._format_row_value_preview(
585
+ values=mixed_type_cells,
586
+ preview_limit=self.dimensions_sample_preview_limit,
587
+ )
588
+ full_output_hint = f" {self._build_full_sample_output_hint()}" if was_truncated else ""
589
+ message_parts.append(f"Mixed-type list cells: {preview_text}.{full_output_hint}")
590
+
591
+ cell_warnings.append(
592
+ ValidationMessage(
593
+ ValidationCategory.TYPE_CLASSIFICATION,
594
+ " ".join(message_parts),
595
+ )
596
+ )
597
+ elif has_numeric:
598
+ numeric_cols.append(col)
599
+ else:
600
+ string_cols.append(col)
601
+
602
+ return numeric_cols, string_cols, mixed_type_cols, cell_errors, cell_warnings
603
+
604
+ def _check_for_semicolons_in_plain_string_cells(self, df: pd.DataFrame) -> list[ValidationMessage]:
605
+ """
606
+ Check for semicolons in plain string (single-value) cells. This is to reduce user confusion with the DivBase query filter syntax,
607
+ which uses semicolons to separate key:value pairs. For example: divbase query tsv "Area:North;Population:1"
608
+ filters for rows where Area is "North" AND Population is 1.
609
+
610
+ A TSV cell containing a semicolon (e.g. "2;4") will be treated as a plain string value and cannot be matched via the query syntax since
611
+ the query parser will split on the semicolon. If the user intended multiple values, they should use Python list notation instead.
612
+ """
613
+ warnings: list[ValidationMessage] = []
614
+ semicolon_cells_by_column: defaultdict[str, list[tuple[int, str]]] = defaultdict(list)
615
+
616
+ for col in df.columns:
617
+ if col == "Sample_ID":
618
+ continue
619
+ series = df[col]
620
+ if not pd.api.types.is_string_dtype(series) and not pd.api.types.is_object_dtype(series):
621
+ continue
622
+
623
+ for idx, cell_value in series.items():
624
+ if not isinstance(cell_value, str):
625
+ continue
626
+ if ";" in cell_value:
627
+ semicolon_cells_by_column[col].append((idx + 2, cell_value))
628
+ break
629
+
630
+ for col, warning_cells in semicolon_cells_by_column.items():
631
+ preview_text, was_truncated = self._format_row_value_preview(
632
+ values=warning_cells,
633
+ preview_limit=self.dimensions_sample_preview_limit,
634
+ )
635
+ full_output_hint = f" {self._build_full_sample_output_hint()}" if was_truncated else ""
636
+ warnings.append(
637
+ ValidationMessage(
638
+ ValidationCategory.FORMAT,
639
+ f"Column '{col}': Found {len(warning_cells)} cell(s) containing a semicolon. "
640
+ "Note that DivBase query syntax uses semicolons to separate filter key:value pairs, "
641
+ "so this exact cell value cannot be matched via queries. "
642
+ f"Affected cells: {preview_text}.{full_output_hint}",
643
+ )
644
+ )
645
+
646
+ return warnings
647
+
648
+ def _check_for_commas_in_plain_string_cells(self, df: pd.DataFrame) -> list[ValidationMessage]:
649
+ """
650
+ Check for commas in plain string cells (in single-value cells, not in multi-values list cells) and warn users about the
651
+ ambiguity that might cause for DivBase filtering since the metadata query filter syntax uses commas to separate filter values.
652
+ For example: divbase query tsv "Area:North,South" filters for rows where Area is "North" OR "South".
653
+
654
+ A TSV cell containing the literal string "North,South" would not match that query, because the query parser splits on commas.
655
+ This helper method warns users about this.
656
+
657
+ Commas inside list notation (e.g. ["North", "South"]) are fine since they are parsed as lists.
658
+ """
659
+ warnings: list[ValidationMessage] = []
660
+ comma_cells_by_column: defaultdict[str, list[tuple[int, str]]] = defaultdict(list)
661
+
662
+ for col in df.columns:
663
+ series = df[col]
664
+ if not pd.api.types.is_string_dtype(series) and not pd.api.types.is_object_dtype(series):
665
+ continue
666
+
667
+ for idx, cell_value in series.items():
668
+ if not isinstance(cell_value, str):
669
+ continue
670
+ if "," in cell_value:
671
+ comma_cells_by_column[col].append((idx + 2, cell_value))
672
+
673
+ for col, warning_cells in comma_cells_by_column.items():
674
+ preview_text, was_truncated = self._format_row_value_preview(
675
+ values=warning_cells,
676
+ preview_limit=self.dimensions_sample_preview_limit,
677
+ )
678
+ full_output_hint = f" {self._build_full_sample_output_hint()}" if was_truncated else ""
679
+ warnings.append(
680
+ ValidationMessage(
681
+ ValidationCategory.FORMAT,
682
+ f"Column '{col}': Found {len(warning_cells)} cell(s) containing a comma. "
683
+ "If you intended to describe multiple values, use Python list notation instead "
684
+ '(e.g. [1, 2] or ["a", "b"]). '
685
+ "If the comma is intentional, note that DivBase query syntax uses "
686
+ "commas to separate filter values. To query for this exact string, "
687
+ "enclose the value in double quotes in your filter (e.g. "
688
+ 'Area:"North,South").'
689
+ f"Affected cells: {preview_text}.{full_output_hint}",
690
+ )
691
+ )
692
+
693
+ return warnings
694
+
695
+ def _validate_dimensions_match(
696
+ self, tsv_samples: set[str], project_samples: set[str]
697
+ ) -> tuple[list[ValidationMessage], list[ValidationMessage]]:
698
+ """
699
+ Validate that TSV samples match project dimensions.
700
+ """
701
+
702
+ # TODO consider the fact that the query route also runs _check_that_dimensions_is_up_to_date_with_VCF_files_in_bucket in tasks.py before even reaching the SharedMetadataValidator...
703
+
704
+ errors: list[ValidationMessage] = []
705
+ warnings: list[ValidationMessage] = []
706
+
707
+ missing_from_project = tsv_samples - project_samples
708
+ if missing_from_project:
709
+ examples, was_truncated = self._format_sample_name_preview(
710
+ sample_names=missing_from_project, preview_limit=self.dimensions_sample_preview_limit
711
+ )
712
+ full_output_hint = f" {self._build_full_sample_output_hint()}" if was_truncated else ""
713
+ errors.append(
714
+ ValidationMessage(
715
+ ValidationCategory.DIMENSIONS,
716
+ f"The following samples in the TSV were not found in the DivBase project's dimensions index ({examples}). "
717
+ "DivBase requires that all samples in the TSV file must be present in the project's dimensions index to be used for queries."
718
+ f"{full_output_hint}",
719
+ )
720
+ )
721
+
722
+ missing_from_tsv = project_samples - tsv_samples
723
+ if missing_from_tsv:
724
+ examples, was_truncated = self._format_sample_name_preview(
725
+ sample_names=missing_from_tsv, preview_limit=self.dimensions_sample_preview_limit
726
+ )
727
+ full_output_hint = f" {self._build_full_sample_output_hint()}" if was_truncated else ""
728
+ warnings.append(
729
+ ValidationMessage(
730
+ ValidationCategory.DIMENSIONS,
731
+ f"The following samples in the DivBase project's dimensions index were not found in the TSV ({examples}). "
732
+ "This is allowed for DivBase metadata TSV files, but please be aware that these samples will not be considered when making queries with this metadata file."
733
+ f"{full_output_hint}",
734
+ )
735
+ )
736
+
737
+ return errors, warnings
738
+
739
+ def _format_sample_name_preview(self, sample_names: set[str], preview_limit: int | None = 20) -> tuple[str, bool]:
740
+ """
741
+ Build a compact sample-name summary for terminal messages. Truncates list of sample names if it exceeds the preview limit (20 by default).
742
+
743
+ Returns only a full list when the list is small, otherwise includes a count and
744
+ a preview of the first 20 samples (default value) to avoid overwhelming CLI output.
745
+ """
746
+ sorted_names = sorted(sample_names, key=lambda sample_name: str(sample_name))
747
+ total = len(sorted_names)
748
+ if preview_limit is None:
749
+ return f"count: {total}, samples: {sorted_names}", False
750
+
751
+ if total <= preview_limit:
752
+ return f"count: {total}, samples: {sorted_names}", False
753
+
754
+ preview = sorted_names[:preview_limit]
755
+ return f"count: {total}, showing first {preview_limit}: {preview}", True
756
+
757
+ def _format_row_value_preview(
758
+ self,
759
+ values: list[tuple[int, Any]],
760
+ preview_limit: int | None = 20,
761
+ ) -> tuple[str, bool]:
762
+ """
763
+ Collect, and when necessary, truncate, row numbers and cell values for error/warning messages about specific cells.
764
+ """
765
+ if preview_limit is None:
766
+ return ", ".join(f"Row {row_num} ('{value}')" for row_num, value in values), False
767
+
768
+ if len(values) <= preview_limit:
769
+ return ", ".join(f"Row {row_num} ('{value}')" for row_num, value in values), False
770
+
771
+ preview = ", ".join(f"Row {row_num} ('{value}')" for row_num, value in values[:preview_limit])
772
+ return f"{preview}, ... ({len(values) - preview_limit} more)", True
773
+
774
+ def _build_full_sample_output_hint(self) -> str:
775
+ """
776
+ When a validator list is truncated in the error/warning messages, give users a hint on how they can see full details.
777
+ """
778
+ return (
779
+ "To view full validation lists, run: "
780
+ "divbase-cli dimensions validate-metadata-file <TSV_FILE_NAME> --project <PROJECT_NAME> "
781
+ "--untruncated."
782
+ )
783
+
784
+ def _collect_statistics(self, df: pd.DataFrame) -> ValidationStats:
785
+ """Collect statistics about the TSV file."""
786
+
787
+ stats = ValidationStats()
788
+ stats.total_columns = len(df.columns)
789
+ stats.user_defined_columns = len(df.columns) - 1 # Exclude Sample_ID
790
+
791
+ project_samples = self.project_samples if self.project_samples is not None else set()
792
+ matching_samples = self.tsv_samples & project_samples
793
+ stats.samples_in_tsv = len(self.tsv_samples)
794
+ stats.samples_matching_project = len(matching_samples)
795
+ stats.total_project_samples = len(project_samples)
796
+
797
+ stats.numeric_column_count = len(self.result.numeric_columns)
798
+ stats.string_column_count = len(self.result.string_columns)
799
+ stats.mixed_type_column_count = len(self.result.mixed_type_columns)
800
+
801
+ # If has_multi_values is True: at least one cell in the DataFrame contains a Python list (multi-value cell).
802
+ stats.has_multi_values = False
803
+ for col in df.columns:
804
+ for val in df[col].dropna():
805
+ if isinstance(val, list):
806
+ stats.has_multi_values = True
807
+ break
808
+ if stats.has_multi_values:
809
+ break
810
+
811
+ stats.empty_cells_per_column = {}
812
+ for col in df.columns:
813
+ if col == "Sample_ID":
814
+ continue
815
+ empty_count = 0
816
+ for val in df[col]:
817
+ if val is None or (isinstance(val, str) and val == ""):
818
+ empty_count += 1
819
+ continue
820
+ if isinstance(val, list):
821
+ continue
822
+ if pd.isna(val):
823
+ empty_count += 1
824
+ if empty_count > 0:
825
+ stats.empty_cells_per_column[col] = empty_count
826
+
827
+ return stats
@@ -0,0 +1,17 @@
1
+ def format_file_size(size_bytes: int | float | None, decimals: int = 2) -> str:
2
+ """
3
+ Converts a file size in bytes to a human-readable format.
4
+
5
+ Uses powers of 1000 so KB, MB, GB, TB and not 1024 KiB, MiB, GiB, TiB.
6
+ """
7
+ if size_bytes is None:
8
+ return "N/A"
9
+ if size_bytes == 0:
10
+ return "0 B"
11
+ power = 1000
12
+ n = 0
13
+ power_labels = {0: "", 1: "K", 2: "M", 3: "G", 4: "T"}
14
+ while size_bytes >= power and n < len(power_labels) - 1:
15
+ size_bytes /= power
16
+ n += 1
17
+ return f"{size_bytes:.{decimals}f} {power_labels[n]}B"
@@ -1 +0,0 @@
1
- __version__ = "0.1.0.dev3"