Skip to content

Commit 341cbe6

Browse files
Adding windows support (cytomining#698)
* add windows to integration test * fixes to enable windows support * windows compatibility * skip python 3.14 on windows * wrap in try finally to avoid leaks * simplify sql query * solve csv sniff issue with load_platemap for windows os * solve windows csv sniffer for load_profiles * move to parquet writing in cli, add legacy csv test * add comments and fix tests for external metadata that are csvs * [pre-commit.ci lite] apply automatic fixes * data are now parquet by default, modernize * clean up test load --------- Co-authored-by: pre-commit-ci-lite[bot] <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com>
1 parent 7514110 commit 341cbe6

13 files changed

Lines changed: 362 additions & 103 deletions

.github/workflows/integration-test.yml

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -113,7 +113,15 @@ jobs:
113113
os:
114114
- macos-14
115115
- ubuntu-24.04
116+
- windows-2022
116117
python-version: ["3.10", "3.11", "3.12", "3.13", "3.14"]
118+
exclude:
119+
# NumPy's MINGW-W64 Windows build for Python 3.14 is self-described as
120+
# "experimental" with crashes expected. Exclude until NumPy ships a
121+
# stable Windows wheel for 3.14.
122+
# https://github.com/numpy/numpy/issues/26038
123+
- os: windows-2022
124+
python-version: "3.14"
117125
runs-on: ${{ matrix.os }}
118126
env:
119127
OS: ${{ matrix.os }}

pycytominer/annotate.py

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ def annotate(
3434
compression_options: Optional[Union[str, dict[str, str]]] = None,
3535
float_format: Optional[str] = None,
3636
cmap_args: Optional[dict[str, Union[str]]] = None,
37+
platemap_sep: Optional[str] = None,
3738
**kwargs,
3839
) -> Union[pd.DataFrame, str]:
3940
"""Add metadata to aggregated profiles.
@@ -64,7 +65,9 @@ def annotate(
6465
external_metadata : pd.DataFrame or file, optional
6566
DataFrame or file with additional metadata information.
6667
Most common use case is a QC.parquet file with QC flags for each profile
67-
that comes from coSMicQC.
68+
that comes from coSMicQC. File paths are loaded via :func:`load_profiles`;
69+
on Windows, CSV/TSV files are not supported — pass a Parquet file or a
70+
pre-loaded DataFrame instead (see the Windows note in :func:`load_profiles`).
6871
external_join_on : str or list, optional
6972
Merge column(s) shared by the annotated profiles and external metadata.
7073
When provided, these keys are used on both sides of the external merge.
@@ -77,6 +80,14 @@ def annotate(
7780
decimal precision.
7881
cmap_args : dict, default None
7982
Potential keyword arguments for annotate_cmap(). See cyto_utils/annotate_custom.py for more details.
83+
platemap_sep : str, optional
84+
Column delimiter for the platemap file (e.g. ``","`` for CSV, ``"\\t"``
85+
for TSV). Only applies when ``platemap`` is a file path — ignored when
86+
a DataFrame is passed directly.
87+
88+
When ``None`` (the default), the delimiter is detected automatically.
89+
Automatic detection can be unreliable on Windows for tab-separated files;
90+
pass ``platemap_sep="\\t"`` explicitly in that case.
8091
8192
Returns
8293
-------
@@ -91,7 +102,7 @@ def annotate(
91102

92103
# Load Data
93104
profiles = load_profiles(profiles)
94-
platemap = load_platemap(platemap, add_metadata_id_to_platemap)
105+
platemap = load_platemap(platemap, add_metadata_id_to_platemap, sep=platemap_sep)
95106

96107
annotated = platemap.merge(
97108
profiles,

pycytominer/cyto_utils/DeepProfiler_processing.py

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -110,14 +110,18 @@ def extract_filename_metadata(
110110
loc : dict
111111
dict with metadata
112112
"""
113+
npz_path = pathlib.PurePath(npz_file)
113114
if delimiter == "/":
114-
site = str(npz_file).split("/")[-1].strip(".npz")
115-
well = str(npz_file).split("/")[-2]
115+
# Layout: .../plate/well/site.npz
116+
site = npz_path.stem
117+
well = npz_path.parent.name
118+
plate = npz_path.parent.parent.name
116119
else:
117-
base_file = os.path.basename(npz_file).strip(".npz").split(delimiter)
120+
# Layout: .../plate/well{delimiter}site.npz
121+
base_file = npz_path.stem.split(delimiter)
118122
site = base_file[-1]
119123
well = base_file[-2]
120-
plate = str(npz_file).split("/")[-2]
124+
plate = npz_path.parent.name
121125

122126
loc = {"site": site, "well": well, "plate": plate}
123127
return loc

pycytominer/cyto_utils/cell_locations.py

Lines changed: 54 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
"""
44

55
import collections
6+
import os
67
import pathlib
78
import tempfile
89
from typing import Optional, Union
@@ -171,19 +172,28 @@ def _download_s3(self, uri: str):
171172

172173
bucket, key = self._parse_s3_path(uri)
173174

174-
with tempfile.NamedTemporaryFile(
175-
delete=False, suffix=pathlib.Path(key).name
176-
) as tmp_file:
177-
self.s3.download_file(bucket, key, tmp_file.name)
175+
# Use mkstemp so the file descriptor is closed before boto3 touches it.
176+
# NamedTemporaryFile holds an exclusive OS lock while open on Windows;
177+
# s3transfer does os.remove() + rename onto the same path internally,
178+
# which raises PermissionError [WinError 32] if the fd is still open.
179+
fd, tmp_path = tempfile.mkstemp(suffix=pathlib.Path(key).name)
180+
os.close(fd)
178181

179-
# Check if the downloaded file exists and has a size greater than 0
180-
tmp_file_path = pathlib.Path(tmp_file.name)
182+
try:
183+
self.s3.download_file(bucket, key, tmp_path)
184+
185+
tmp_file_path = pathlib.Path(tmp_path)
181186
if tmp_file_path.exists() and tmp_file_path.stat().st_size > 0:
182-
return tmp_file.name
183-
else:
184-
raise ValueError(
185-
f"Downloaded file '{tmp_file.name}' is empty or does not exist."
186-
)
187+
return tmp_path
188+
189+
raise ValueError(
190+
f"Downloaded file '{tmp_path}' is empty or does not exist."
191+
)
192+
except Exception:
193+
tmp_file_path = pathlib.Path(tmp_path)
194+
if tmp_file_path.exists():
195+
tmp_file_path.unlink()
196+
raise
187197

188198
def _load_metadata(self):
189199
"""Load the metadata into a Pandas DataFrame
@@ -358,38 +368,39 @@ def _get_joined_image_nuclei_tables(self):
358368
# get the sqlalchemy.engine.Engine object for the single_cell file
359369
temp_single_cell_input, engine = self._get_single_cell_engine()
360370

361-
# check that the single_cell file has the required tables and columns
362-
self._check_single_cell_correctness(engine)
363-
364-
image_index_str = ", ".join(self.image_key)
365-
366-
# merge the Image and Nuclei tables in SQL
367-
368-
join_query = f"""
369-
SELECT Nuclei.{self.table_column},Nuclei.{self.image_column},Nuclei.{self.object_column},Nuclei.{self.cell_x_loc},Nuclei.{self.cell_y_loc},Image.{image_index_str}
370-
FROM Nuclei
371-
INNER JOIN Image
372-
ON Nuclei.{self.image_column} = Image.{self.image_column} and Nuclei.{self.table_column} = Image.{self.table_column};
373-
"""
374-
375-
column_types = {
376-
self.image_column: "int64",
377-
self.table_column: "int64",
378-
self.object_column: "int64",
379-
self.cell_x_loc: "float",
380-
self.cell_y_loc: "float",
381-
}
382-
383-
for image_key in self.image_key:
384-
column_types[image_key] = "str"
385-
386-
joined_df = pd.read_sql_query(join_query, engine, dtype=column_types)
387-
388-
# if the single_cell file was downloaded from S3, delete the temporary file
389-
if temp_single_cell_input is not None:
390-
pathlib.Path(temp_single_cell_input).unlink()
391-
392-
return joined_df
371+
try:
372+
# check that the single_cell file has the required tables and columns
373+
self._check_single_cell_correctness(engine)
374+
375+
# CAST each column at the database level.
376+
# SQLite uses type affinity rather than strict column types, so
377+
# values may not match their declared type; CAST enforces the
378+
# expected types in the query itself rather than via a slower
379+
# post-hoc pandas dtype conversion.
380+
join_query = f"""
381+
SELECT
382+
CAST(Nuclei.{self.table_column} AS INTEGER) AS {self.table_column},
383+
CAST(Nuclei.{self.image_column} AS INTEGER) AS {self.image_column},
384+
CAST(Nuclei.{self.object_column} AS INTEGER) AS {self.object_column},
385+
CAST(Nuclei.{self.cell_x_loc} AS REAL) AS {self.cell_x_loc},
386+
CAST(Nuclei.{self.cell_y_loc} AS REAL) AS {self.cell_y_loc},
387+
{", ".join(f"CAST(Image.{k} AS TEXT) AS {k}" for k in self.image_key)}
388+
FROM Nuclei
389+
INNER JOIN Image
390+
ON Nuclei.{self.image_column} = Image.{self.image_column}
391+
AND Nuclei.{self.table_column} = Image.{self.table_column};
392+
"""
393+
394+
return pd.read_sql_query(join_query, engine)
395+
finally:
396+
# Always dispose the engine and remove the temp file.
397+
# On Windows, SQLAlchemy's connection pool keeps the SQLite file open;
398+
# unlink() raises PermissionError [WinError 32] unless disposed first.
399+
engine.dispose()
400+
if temp_single_cell_input is not None:
401+
temp_path = pathlib.Path(temp_single_cell_input)
402+
if temp_path.exists():
403+
temp_path.unlink()
393404

394405
def _load_single_cell(self):
395406
"""Load the required columns from the `Image` and `Nuclei` tables in the single_cell file or sqlalchemy.engine.Engine object into a Pandas DataFrame

pycytominer/cyto_utils/collate.py

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -240,7 +240,13 @@ def collate(
240240
add_image_features=add_image_features,
241241
image_feature_categories=image_feature_categories,
242242
)
243-
database.aggregate_profiles(output_file=str(aggregated_file))
243+
try:
244+
database.aggregate_profiles(output_file=str(aggregated_file))
245+
finally:
246+
# Release the SQLite connection so the file can be removed or renamed on
247+
# Windows (which holds an exclusive lock on open database files).
248+
database.conn.close()
249+
database.engine.dispose()
244250

245251
if aws_remote:
246252
if printtoscreen:

pycytominer/cyto_utils/load.py

Lines changed: 39 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
import csv
66
import gzip
77
import pathlib
8+
import sys
89
from typing import Any, Optional, Union
910

1011
import numpy as np
@@ -390,33 +391,63 @@ def load_profiles(
390391
if anndata_type := is_anndata(profiles):
391392
return read_anndata(profiles, anndata_type)
392393

393-
# otherwise, assume its a csv/tsv file and infer the delimiter
394+
# CSV/TSV fallback — not supported on Windows.
395+
# Python's csv.Sniffer unreliably detects tab-separated files on Windows
396+
# (https://github.com/python/cpython/issues/119123), and there is no
397+
# safe way to auto-detect the delimiter cross-platform without risking
398+
# silently loading data with the wrong separator.
399+
if sys.platform == "win32":
400+
raise OSError(
401+
"Loading CSV/TSV profiles via automatic delimiter detection is not "
402+
"supported on Windows (see https://github.com/python/cpython/issues/119123).\n"
403+
"We recommend reprocessing your CellProfiler output with CytoTable "
404+
"(https://github.com/cytomining/CytoTable) to produce a standardised "
405+
"Parquet file, which is supported on all platforms and is the preferred "
406+
"input format for pycytominer.\n"
407+
"If reprocessing is not an option, load the file manually with "
408+
"pd.read_csv(path, sep=',') or pd.read_csv(path, sep='\\t') and pass "
409+
"the resulting DataFrame directly."
410+
)
394411
delim = infer_delim(profiles)
395412
return pd.read_csv(str(profiles), sep=delim)
396413

397414

398415
def load_platemap(
399-
platemap: Union[str, pd.DataFrame], add_metadata_id=True
416+
platemap: Union[str, pd.DataFrame],
417+
add_metadata_id: bool = True,
418+
sep: Optional[str] = None,
400419
) -> pd.DataFrame:
401420
"""
402-
Unless a dataframe is provided, load the given platemap dataframe from path or string
421+
Unless a dataframe is provided, load the given platemap dataframe from path or string.
403422
404423
Parameters
405424
----------
406425
platemap : pd.DataFrame or str
407-
location or actual pd.DataFrame of platemap file
426+
Location or actual pd.DataFrame of platemap file.
427+
428+
add_metadata_id : bool, default True
429+
Whether ``Metadata_`` should be prepended to all platemap columns.
408430
409-
add_metadata_id : bool
410-
boolean if ``Metadata_`` should be appended to all platemap columns
431+
sep : str, optional
432+
The column delimiter used in the platemap file (e.g. ``","`` for CSV,
433+
``"\\t"`` for TSV). Only relevant when ``platemap`` is a file path rather
434+
than a DataFrame — has no effect when a DataFrame is passed directly.
435+
436+
When ``None`` (the default), the delimiter is detected automatically via
437+
:func:`infer_delim`. Automatic detection relies on Python's
438+
``csv.Sniffer``, which can be unreliable on Windows for tab-separated
439+
files (see `cpython#119123
440+
<https://github.com/python/cpython/issues/119123>`_). If you are on
441+
Windows and loading a TSV platemap, pass ``sep="\\t"`` explicitly.
411442
412443
Returns
413444
-------
414445
platemap : pd.DataFrame
415-
pandas DataFrame of profiles
446+
pandas DataFrame of platemap.
416447
"""
417448
if not isinstance(platemap, pd.DataFrame):
418449
try:
419-
delim = infer_delim(platemap)
450+
delim = sep if sep is not None else infer_delim(platemap)
420451
platemap = pd.read_csv(platemap, sep=delim)
421452
except FileNotFoundError:
422453
raise FileNotFoundError(f"{platemap} platemap file not found")

tests/test_annotate.py

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,40 @@ def test_annotate():
6161
pd.testing.assert_frame_equal(result, expected_result)
6262

6363

64+
def test_annotate_platemap_sep(tmp_path):
65+
"""platemap_sep bypasses infer_delim — runs on all platforms including Windows."""
66+
# Write platemap as TSV and CSV to tmp_path
67+
tsv_path = tmp_path / "platemap.tsv"
68+
csv_path = tmp_path / "platemap.csv"
69+
PLATEMAP_DF.to_csv(tsv_path, sep="\t", index=False)
70+
PLATEMAP_DF.to_csv(csv_path, sep=",", index=False)
71+
72+
expected_result = (
73+
PLATEMAP_DF
74+
.merge(DATA_DF, left_on="well_position", right_on="Metadata_Well")
75+
.rename(columns={"gene": "Metadata_gene"})
76+
.drop("well_position", axis="columns")
77+
)
78+
79+
# TSV platemap loaded via explicit platemap_sep
80+
result_tsv = annotate(
81+
profiles=DATA_DF,
82+
platemap=str(tsv_path),
83+
join_on=["Metadata_well_position", "Metadata_Well"],
84+
platemap_sep="\t",
85+
)
86+
pd.testing.assert_frame_equal(result_tsv, expected_result)
87+
88+
# CSV platemap loaded via explicit platemap_sep
89+
result_csv = annotate(
90+
profiles=DATA_DF,
91+
platemap=str(csv_path),
92+
join_on=["Metadata_well_position", "Metadata_Well"],
93+
platemap_sep=",",
94+
)
95+
pd.testing.assert_frame_equal(result_csv, expected_result)
96+
97+
6498
def test_annotate_platemap_naming():
6599
# Test annotate with the same column name in platemap and data.
66100
platemap_modified_df = PLATEMAP_DF.copy().rename(

0 commit comments

Comments
 (0)