|
3 | 3 | """ |
4 | 4 |
|
5 | 5 | import collections |
| 6 | +import os |
6 | 7 | import pathlib |
7 | 8 | import tempfile |
8 | 9 | from typing import Optional, Union |
@@ -171,19 +172,28 @@ def _download_s3(self, uri: str): |
171 | 172 |
|
172 | 173 | bucket, key = self._parse_s3_path(uri) |
173 | 174 |
|
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) |
178 | 181 |
|
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) |
181 | 186 | 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 |
187 | 197 |
|
188 | 198 | def _load_metadata(self): |
189 | 199 | """Load the metadata into a Pandas DataFrame |
@@ -358,38 +368,39 @@ def _get_joined_image_nuclei_tables(self): |
358 | 368 | # get the sqlalchemy.engine.Engine object for the single_cell file |
359 | 369 | temp_single_cell_input, engine = self._get_single_cell_engine() |
360 | 370 |
|
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() |
393 | 404 |
|
394 | 405 | def _load_single_cell(self): |
395 | 406 | """Load the required columns from the `Image` and `Nuclei` tables in the single_cell file or sqlalchemy.engine.Engine object into a Pandas DataFrame |
|
0 commit comments