pma-python 2.0.0.184__tar.gz → 2.0.0.186__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 (20) hide show
  1. {pma_python-2.0.0.184 → pma_python-2.0.0.186}/PKG-INFO +1 -1
  2. {pma_python-2.0.0.184 → pma_python-2.0.0.186}/pma_python/core.py +346 -206
  3. {pma_python-2.0.0.184 → pma_python-2.0.0.186}/pma_python/pma_core_client.py +65 -34
  4. pma_python-2.0.0.186/pma_python/version.py +1 -0
  5. {pma_python-2.0.0.184 → pma_python-2.0.0.186}/pma_python.egg-info/PKG-INFO +1 -1
  6. {pma_python-2.0.0.184 → pma_python-2.0.0.186}/pma_python.egg-info/SOURCES.txt +2 -1
  7. pma_python-2.0.0.186/tests/test_slide_package.py +143 -0
  8. pma_python-2.0.0.184/pma_python/version.py +0 -1
  9. {pma_python-2.0.0.184 → pma_python-2.0.0.186}/README.md +0 -0
  10. {pma_python-2.0.0.184 → pma_python-2.0.0.186}/pma_python/__init__.py +0 -0
  11. {pma_python-2.0.0.184 → pma_python-2.0.0.186}/pma_python/control.py +0 -0
  12. {pma_python-2.0.0.184 → pma_python-2.0.0.186}/pma_python/core_admin.py +0 -0
  13. {pma_python-2.0.0.184 → pma_python-2.0.0.186}/pma_python/pma.py +0 -0
  14. {pma_python-2.0.0.184 → pma_python-2.0.0.186}/pma_python/view.py +0 -0
  15. {pma_python-2.0.0.184 → pma_python-2.0.0.186}/pma_python.egg-info/dependency_links.txt +0 -0
  16. {pma_python-2.0.0.184 → pma_python-2.0.0.186}/pma_python.egg-info/not-zip-safe +0 -0
  17. {pma_python-2.0.0.184 → pma_python-2.0.0.186}/pma_python.egg-info/requires.txt +0 -0
  18. {pma_python-2.0.0.184 → pma_python-2.0.0.186}/pma_python.egg-info/top_level.txt +0 -0
  19. {pma_python-2.0.0.184 → pma_python-2.0.0.186}/setup.cfg +0 -0
  20. {pma_python-2.0.0.184 → pma_python-2.0.0.186}/setup.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: pma_python
3
- Version: 2.0.0.184
3
+ Version: 2.0.0.186
4
4
  Summary: Universal viewing of digital microscopy, whole slide imaging and digital pathology data
5
5
  Home-page: https://github.com/pathomation/pma_python
6
6
  Author: Pathomation
@@ -14,6 +14,7 @@ import os
14
14
  import datetime
15
15
  import io
16
16
  import re
17
+ import traceback
17
18
  import pandas as pd
18
19
  import requests
19
20
  from requests_toolbelt.multipart.encoder import MultipartEncoder, MultipartEncoderMonitor
@@ -1901,29 +1902,303 @@ def get_annotation_distance(slideRef, layerID, annotationID, sessionID=None, ver
1901
1902
  # **************************************#
1902
1903
  # === Uploader === #
1903
1904
  # **************************************#
1904
- FOUR_GB = 4 * 1024 * 1024 * 1024
1905
- FIVE_GB = 5 * 1024 * 1024 * 1024
1905
+ def _normalize_upload_directory(upload_directory: str) -> str:
1906
+ if upload_directory is None:
1907
+ raise ValueError("target_folder cannot be empty")
1908
+ normalized = upload_directory.strip()
1909
+ if normalized == "/":
1910
+ return ""
1911
+ normalized = normalized.lstrip("/")
1912
+ return normalized.rstrip("/")
1913
+
1914
+
1915
+ def _join_virtual_path(parent: str, child: str) -> str:
1916
+ parent = (parent or "").strip("/")
1917
+ child = (child or "").lstrip("/")
1918
+ return f"{parent}/{child}" if parent else child
1919
+
1920
+
1921
+ def _normalize_upload_type(upload_type):
1922
+ if isinstance(upload_type, str):
1923
+ return upload_type.strip().lower()
1924
+ return upload_type
1925
+
1926
+
1927
+ def _is_upload_type(upload_type, *candidates) -> bool:
1928
+ normalized = _normalize_upload_type(upload_type)
1929
+ for candidate in candidates:
1930
+ if isinstance(candidate, str):
1931
+ if normalized == candidate.lower():
1932
+ return True
1933
+ elif normalized == candidate:
1934
+ return True
1935
+ return False
1936
+
1937
+
1938
+ def _companion_folders(slide_path: str) -> list[str]:
1939
+ """
1940
+ Folders that hold the image data of a multi-file slide, by vendor naming convention:
1941
+ - "<name>/" e.g. 3DHistech MRXS
1942
+ - "_<name>_/" e.g. Olympus/Evident VSI
1943
+ """
1944
+ directory = os.path.dirname(slide_path)
1945
+ stem = os.path.splitext(os.path.basename(slide_path))[0]
1946
+ return [os.path.join(directory, stem), os.path.join(directory, f"_{stem}_")]
1947
+
1948
+
1949
+ def _pma_start_files_for_slide(slide_path: str) -> list[str]:
1950
+ """
1951
+ Ask PMA.start which local files belong to this slide (it knows every multi-file format).
1952
+ Best effort: returns [] when PMA.start is not running or cannot answer.
1953
+ """
1954
+ try:
1955
+ return list(get_files_for_slide(slide_path, _pma_pmacoreliteSessionID) or [])
1956
+ except Exception:
1957
+ if pma._pma_debug == True:
1958
+ print("PMA.start could not enumerate the files for", slide_path)
1959
+ return []
1960
+
1961
+
1962
+ def _collect_slide_package_files(slide_path: str):
1963
+ """
1964
+ All local files that make up a slide: the main file first, then its image data files.
1965
+ Files are found both by vendor folder conventions (see _companion_folders) and by asking
1966
+ PMA.start, so a multi-file slide is never uploaded without its image data.
1967
+ Relative paths are relative to the folder that contains the main file.
1968
+ """
1969
+ if not os.path.isfile(slide_path):
1970
+ raise ValueError(f"File not found: {slide_path}")
1971
+
1972
+ slide_path = os.path.abspath(slide_path)
1973
+ base_dir = os.path.dirname(slide_path)
1974
+ main_name = os.path.basename(slide_path)
1975
+
1976
+ def key(path):
1977
+ return os.path.normcase(os.path.abspath(path))
1978
+
1979
+ extra = {}
1980
+
1981
+ def add(path):
1982
+ path = os.path.abspath(path)
1983
+ if key(path) == key(slide_path) or not os.path.isfile(path):
1984
+ return
1985
+ rel = os.path.relpath(path, base_dir)
1986
+ if rel.startswith(os.pardir) or os.path.isabs(rel):
1987
+ # never upload files from outside the slide's own folder
1988
+ return
1989
+ extra.setdefault(key(path), (path, rel.replace("\\", "/")))
1990
+
1991
+ for folder in _companion_folders(slide_path):
1992
+ if os.path.isdir(folder):
1993
+ for root, dirnames, filenames in os.walk(folder):
1994
+ dirnames.sort()
1995
+ for filename in sorted(filenames):
1996
+ add(os.path.join(root, filename))
1997
+
1998
+ for path in _pma_start_files_for_slide(slide_path):
1999
+ add(path)
2000
+
2001
+ package_files = [{
2002
+ "local_path": slide_path,
2003
+ "relative_path": main_name,
2004
+ "is_main": True,
2005
+ "size": os.stat(slide_path).st_size
2006
+ }]
2007
+ for path, rel in sorted(extra.values(), key=lambda item: item[1].lower()):
2008
+ package_files.append({
2009
+ "local_path": path,
2010
+ "relative_path": rel,
2011
+ "is_main": False,
2012
+ "size": os.stat(path).st_size
2013
+ })
2014
+
2015
+ return package_files
2016
+
2017
+
2018
+ def _build_upload_urls(server_url: str, session_id: str, upload_id: int, package_files) -> list[str]:
2019
+ base = server_url if server_url.endswith("/") else server_url + "/"
2020
+ encoded_session = pma._pma_q(session_id)
2021
+ urls = []
2022
+ for file in package_files:
2023
+ encoded_path = pma._pma_q(file["relative_path"])
2024
+ urls.append(f"{base}transfer/Upload/{upload_id}?sessionId={encoded_session}&path={encoded_path}")
2025
+ return urls
2026
+
1906
2027
 
1907
2028
  def _get_slide_package_size(slide_path: str) -> tuple[int, int]:
1908
2029
  """Return total bytes and maximum single-file bytes for a slide package."""
1909
- total_size = 0
1910
- max_size = 0
2030
+ files = _collect_slide_package_files(slide_path)
2031
+ total_size = sum(file["size"] for file in files)
2032
+ max_size = max(file["size"] for file in files) if files else 0
2033
+ return total_size, max_size
1911
2034
 
1912
- files = [slide_path]
1913
- slide_name_no_ext = os.path.splitext(os.path.basename(slide_path))[0]
1914
- slide_dir = os.path.join(os.path.dirname(slide_path), slide_name_no_ext)
1915
2035
 
1916
- if os.path.isdir(slide_dir):
1917
- for root, _, filenames in os.walk(slide_dir):
1918
- for filename in filenames:
1919
- files.append(os.path.join(root, filename))
2036
+ async def _poll_image_info(
2037
+ slide_ref: str,
2038
+ session_id: str,
2039
+ verify=True,
2040
+ max_attempts: int = 5,
2041
+ interval_seconds: float = 1.0,
2042
+ ) -> bool:
2043
+ for attempt in range(max_attempts):
2044
+ try:
2045
+ await asyncio.to_thread(
2046
+ get_slide_info,
2047
+ slide_ref,
2048
+ sessionID=session_id,
2049
+ verify=verify
2050
+ )
2051
+ return True
2052
+ except Exception:
2053
+ if attempt >= max_attempts - 1:
2054
+ return False
2055
+ await asyncio.sleep((attempt + 1) * interval_seconds)
1920
2056
 
1921
- for filepath in files:
1922
- size = os.stat(filepath).st_size
1923
- total_size += size
1924
- max_size = max(max_size, size)
2057
+ return False
2058
+
2059
+
2060
+ async def _upload_package_files(
2061
+ *,
2062
+ client: PmaCoreClient,
2063
+ session_id: str,
2064
+ upload_directory: str,
2065
+ package_files,
2066
+ progress_callback=None,
2067
+ verify=True,
2068
+ ) -> tuple[bool, str | None]:
2069
+ try:
2070
+ header = UploadHeaderModel(
2071
+ path=upload_directory,
2072
+ files=[
2073
+ UploadFileModel(
2074
+ isMain=file["is_main"],
2075
+ length=file["size"],
2076
+ path=file["relative_path"]
2077
+ )
2078
+ for file in package_files
2079
+ ]
2080
+ )
2081
+
2082
+ resp = await client.upload_header(header, session_id)
2083
+
2084
+ is_azure = _is_upload_type(resp.upload_type, 2, "azure")
2085
+ is_amazon = _is_upload_type(resp.upload_type, 1, "amazons3")
2086
+
2087
+ def _normalize_relative_path(path: str) -> str:
2088
+ return (path or "").replace("\\", "/").lstrip("/")
2089
+
2090
+ multipart_by_path = {
2091
+ _normalize_relative_path(mf.file_path): mf
2092
+ for mf in (resp.multipart_files or [])
2093
+ }
2094
+
2095
+ # Keep aggregate progress in bytes across all files (same semantics as the rest of this SDK).
2096
+ total_bytes = sum(file["size"] for file in package_files)
2097
+ uploaded_size = [0] * len(package_files)
2098
+
2099
+ def update_progress(index: int, loaded: int):
2100
+ capped = min(max(loaded, 0), package_files[index]["size"])
2101
+ uploaded_size[index] = capped
2102
+ if callable(progress_callback) and total_bytes > 0:
2103
+ progress_callback(sum(uploaded_size), total_bytes)
2104
+
2105
+ if callable(progress_callback):
2106
+ progress_callback(0, total_bytes)
2107
+
2108
+ has_direct_urls = bool(resp.urls)
2109
+ direct_urls = list(resp.urls or [])
2110
+ fallback_urls = _build_upload_urls(client.server_url, session_id, resp.id, package_files)
2111
+
2112
+ for index, file in enumerate(package_files):
2113
+ direct_url = direct_urls[index] if index < len(direct_urls) else None
2114
+ relative_path = _normalize_relative_path(file["relative_path"])
2115
+ multipart_file = multipart_by_path.get(relative_path)
2116
+
2117
+ def per_file_progress(sent: int, _total: int, idx=index):
2118
+ update_progress(idx, sent)
2119
+
2120
+ with open(file["local_path"], "rb") as stream:
2121
+ if is_amazon and multipart_file is not None:
2122
+ remote_file_path = _join_virtual_path(upload_directory, multipart_file.file_path)
2123
+ await client.upload_multipart_file_to_s3(
2124
+ multipart_file,
2125
+ remote_file_path,
2126
+ stream,
2127
+ session_id,
2128
+ progress_callback=per_file_progress
2129
+ )
2130
+ elif is_azure:
2131
+ if not direct_url:
2132
+ return False, f"No upload URL provided for file {file['relative_path']}"
2133
+ await client.upload_file(
2134
+ resp.id,
2135
+ resp.upload_type,
2136
+ direct_url,
2137
+ file["relative_path"],
2138
+ stream,
2139
+ session_id,
2140
+ total_bytes=file["size"],
2141
+ progress_callback=per_file_progress,
2142
+ method="PUT",
2143
+ verify=verify
2144
+ )
2145
+ else:
2146
+ upload_url = direct_url or (fallback_urls[index] if index < len(fallback_urls) else None)
2147
+ method = "PUT" if bool(direct_url) else "POST"
2148
+ if not upload_url:
2149
+ return False, f"No upload URL provided for file {file['relative_path']}"
2150
+ await client.upload_file(
2151
+ resp.id,
2152
+ resp.upload_type,
2153
+ upload_url,
2154
+ file["relative_path"],
2155
+ stream,
2156
+ session_id,
2157
+ total_bytes=file["size"],
2158
+ progress_callback=per_file_progress,
2159
+ method=method,
2160
+ verify=verify
2161
+ )
2162
+
2163
+ update_progress(index, file["size"])
2164
+
2165
+ finalize_url = f"{client.server_url}transfer/Upload/{resp.id}"
2166
+ finalize_response = await asyncio.to_thread(
2167
+ requests.get,
2168
+ finalize_url,
2169
+ params={"sessionId": session_id},
2170
+ verify=verify
2171
+ )
2172
+
2173
+ if finalize_response.status_code < 200 or finalize_response.status_code >= 300:
2174
+ try:
2175
+ payload = finalize_response.json()
2176
+ error_message = payload.get("Message") or payload.get("ExceptionMessage")
2177
+ except Exception:
2178
+ error_message = finalize_response.text
2179
+ return False, f"Upload finalize failed: {error_message}"
2180
+
2181
+ main_slide_path = _join_virtual_path(upload_directory, package_files[0]["relative_path"])
2182
+ image_ready = await _poll_image_info(
2183
+ main_slide_path,
2184
+ session_id,
2185
+ verify=verify,
2186
+ max_attempts=5,
2187
+ interval_seconds=1.0
2188
+ )
2189
+
2190
+ if not image_ready:
2191
+ return False, "Slide upload completed, but unable to fetch image information."
2192
+
2193
+ if callable(progress_callback):
2194
+ progress_callback(total_bytes, total_bytes)
2195
+
2196
+ return True, None
2197
+ except Exception as e:
2198
+ if os.getenv("PMA_UPLOAD_DEBUG_TRACE", "").lower() in ("1", "true", "yes"):
2199
+ return False, f"{e}\n{traceback.format_exc()}"
2200
+ return False, str(e)
1925
2201
 
1926
- return total_size, max_size
1927
2202
 
1928
2203
  async def upload(
1929
2204
  *,
@@ -1939,7 +2214,7 @@ async def upload(
1939
2214
  Automatically:
1940
2215
  - calculates full slide size (file + folder + nested files)
1941
2216
  - detects server upload type
1942
- - routes upload to legacy transfer or large file uploader
2217
+ - routes according to server upload strategy
1943
2218
  """
1944
2219
 
1945
2220
  if not os.path.isfile(slide_path):
@@ -1959,18 +2234,18 @@ async def upload(
1959
2234
 
1960
2235
  try:
1961
2236
  client = PmaCoreClient(pma_core_url)
2237
+ package_files = _collect_slide_package_files(slide_path)
1962
2238
 
1963
- file_size = os.stat(slide_path).st_size
1964
- file_name = os.path.basename(slide_path)
1965
-
2239
+ normalized_upload_directory = _normalize_upload_directory(upload_directory)
1966
2240
  header = UploadHeaderModel(
1967
- path=upload_directory,
2241
+ path=normalized_upload_directory,
1968
2242
  files=[
1969
2243
  UploadFileModel(
1970
- isMain=True,
1971
- length=file_size,
1972
- path=file_name
2244
+ isMain=file["is_main"],
2245
+ length=file["size"],
2246
+ path=file["relative_path"]
1973
2247
  )
2248
+ for file in package_files
1974
2249
  ]
1975
2250
  )
1976
2251
 
@@ -1983,7 +2258,7 @@ async def upload(
1983
2258
  # ================================
1984
2259
 
1985
2260
  # UploadType 0 → always small uploader
1986
- if resp.upload_type == 0:
2261
+ if _is_upload_type(resp.upload_type, 0, "filesystem"):
1987
2262
  print("UploadType 0 → forcing SMALL uploader")
1988
2263
 
1989
2264
  return upload_legacy(
@@ -1993,33 +2268,21 @@ async def upload(
1993
2268
  progress_callback=progress_callback
1994
2269
  )
1995
2270
 
1996
- # Otherwise choose based on total package size or safe single-request cap
1997
- if total_size < FOUR_GB and max_file_size < FIVE_GB:
1998
- print("Using SMALL file uploader")
1999
-
2000
- return upload_legacy(
2001
- session_id=session_id,
2002
- slide_path=slide_path,
2003
- upload_directory=upload_directory,
2004
- progress_callback=progress_callback
2005
- )
2006
-
2007
- else:
2008
- print("Using LARGE file uploader")
2009
-
2010
- return await upload_file_over_5gb(
2011
- pma_core_url=pma_core_url,
2012
- session_id=session_id,
2013
- slide_path=slide_path,
2014
- upload_directory=upload_directory,
2015
- progress_callback=progress_callback
2016
- )
2271
+ # For cloud uploads, follow server strategy (S3 multipart, Azure blocks, or direct URLs).
2272
+ print("Using SERVER-DRIVEN uploader")
2273
+ return await upload_file_on_cloud(
2274
+ pma_core_url=pma_core_url,
2275
+ session_id=session_id,
2276
+ slide_path=slide_path,
2277
+ upload_directory=upload_directory,
2278
+ progress_callback=progress_callback
2279
+ )
2017
2280
 
2018
2281
  except Exception as e:
2019
2282
  return False, str(e)
2020
2283
 
2021
2284
  # **************************************#
2022
- # === Small Files < 5bg Uploader === #
2285
+ # === Filesystem Uploader === #
2023
2286
  # **************************************#
2024
2287
  def upload_legacy(slide_path, upload_directory, session_id, progress_callback=None, verify=True):
2025
2288
  """
@@ -2157,15 +2420,16 @@ def upload_legacy(slide_path, upload_directory, session_id, progress_callback=No
2157
2420
 
2158
2421
 
2159
2422
  # **************************************#
2160
- # === Large Files > 5gb Uploader === #
2423
+ # === Cloud Uploader === #
2161
2424
  # **************************************#
2162
- async def upload_file_over_5gb(
2425
+ async def upload_file_on_cloud(
2163
2426
  *,
2164
2427
  pma_core_url: str,
2165
2428
  session_id: str,
2166
2429
  slide_path: str,
2167
2430
  upload_directory: str,
2168
2431
  progress_callback=None,
2432
+ verify=True,
2169
2433
  ):
2170
2434
  """
2171
2435
  Asynchronous large-file upload implementation using PmaCoreClient.
@@ -2176,90 +2440,22 @@ async def upload_file_over_5gb(
2176
2440
  - global progress tracking across all uploaded files
2177
2441
  """
2178
2442
 
2179
- # ===== GETTING ALL FILES =====
2180
- all_files = []
2181
-
2182
- all_files.append(slide_path)
2183
-
2184
- vsi_name_no_ext = os.path.splitext(os.path.basename(slide_path))[0]
2185
- local_stack_root = os.path.join(os.path.dirname(slide_path), vsi_name_no_ext)
2186
-
2187
- # stack files
2188
- if os.path.isdir(local_stack_root):
2189
- for root, _, files in os.walk(local_stack_root):
2190
- for f in files:
2191
- all_files.append(os.path.join(root, f))
2192
-
2193
- # ===== general size =====
2194
- total_bytes = sum(os.stat(f).st_size for f in all_files)
2195
-
2196
- # ===== GLOBAL TRACKER =====
2197
- class GlobalProgressTracker:
2198
- def __init__(self, total_bytes, callback):
2199
- self.total_bytes = total_bytes
2200
- self.callback = callback
2201
- self.sent_total = 0
2202
-
2203
- def add(self, delta):
2204
- self.sent_total += delta
2205
- if self.callback and self.total_bytes > 0:
2206
- self.callback(self.sent_total, self.total_bytes)
2207
-
2208
- tracker = GlobalProgressTracker(total_bytes, progress_callback)
2209
-
2210
- # ===== FILE CALLBACK WRAPPER =====
2211
- def make_file_cb():
2212
- last_sent = 0
2213
-
2214
- def _cb(sent, total):
2215
- nonlocal last_sent
2216
- delta = sent - last_sent
2217
- last_sent = sent
2218
- tracker.add(delta)
2443
+ if not os.path.isfile(slide_path):
2444
+ return False, f"File not found: {slide_path}"
2219
2445
 
2220
- return _cb
2446
+ normalized_upload_directory = _normalize_upload_directory(upload_directory)
2447
+ package_files = _collect_slide_package_files(slide_path)
2221
2448
 
2222
- ok, err = await large_files_upload_package(
2223
- pma_core_url=pma_core_url,
2449
+ client = PmaCoreClient(pma_core_url)
2450
+ return await _upload_package_files(
2451
+ client=client,
2224
2452
  session_id=session_id,
2225
- slide_path=slide_path,
2226
- upload_directory=upload_directory,
2227
- progress_callback=make_file_cb()
2453
+ upload_directory=normalized_upload_directory,
2454
+ package_files=package_files,
2455
+ progress_callback=progress_callback,
2456
+ verify=verify,
2228
2457
  )
2229
2458
 
2230
- if not ok:
2231
- return False, f"Upload failed: {err}"
2232
-
2233
- # ===== STACK =====
2234
- if os.path.isdir(local_stack_root):
2235
-
2236
- for root, _, files in os.walk(local_stack_root):
2237
-
2238
- for file_name in files:
2239
-
2240
- local_file = os.path.join(root, file_name)
2241
-
2242
- rel_inside_stack = os.path.relpath(
2243
- local_file,
2244
- local_stack_root
2245
- ).replace("\\", "/")
2246
-
2247
- remote_dir = f"{upload_directory}/{vsi_name_no_ext}/{os.path.dirname(rel_inside_stack)}"
2248
- remote_dir = remote_dir.rstrip("/")
2249
-
2250
- ok, err = await large_files_upload_package(
2251
- pma_core_url=pma_core_url,
2252
- session_id=session_id,
2253
- slide_path=local_file,
2254
- upload_directory=remote_dir,
2255
- progress_callback=make_file_cb()
2256
- )
2257
-
2258
- if not ok:
2259
- return False, f"Stack upload failed: {local_file} -> {err}"
2260
-
2261
- return True, None
2262
-
2263
2459
 
2264
2460
  async def large_files_upload_package(
2265
2461
  *,
@@ -2268,93 +2464,37 @@ async def large_files_upload_package(
2268
2464
  slide_path: str,
2269
2465
  upload_directory: str,
2270
2466
  progress_callback=None,
2467
+ verify=True,
2271
2468
  ) -> tuple[bool, str | None]:
2272
2469
  """
2273
- Low-level async upload routine for a single file.
2470
+ Low-level async upload wrapper for a single file.
2274
2471
 
2275
2472
  Performs:
2276
2473
  - upload header request
2277
2474
  - multipart or direct upload
2278
- - upload completion polling
2475
+ - upload finalization + image info polling
2279
2476
  """
2280
2477
 
2281
- try:
2282
- client = PmaCoreClient(pma_core_url)
2283
- print("trust_env:", client.session.trust_env)
2284
-
2285
- if not os.path.isfile(slide_path):
2286
- return False, f"File not found: {slide_path}"
2287
-
2288
- file_size = os.stat(slide_path).st_size
2289
- file_name = os.path.basename(slide_path)
2290
-
2291
- # ===== HEADER =====
2292
- header = UploadHeaderModel(
2293
- path=upload_directory,
2294
- files=[
2295
- UploadFileModel(
2296
- isMain=True,
2297
- length=file_size,
2298
- path=file_name
2299
- )
2300
- ]
2301
- )
2302
-
2303
- # ===== SEND HEADER =====
2304
- resp = await client.upload_header(header, session_id)
2305
-
2306
- # print("UploadType:", resp.upload_type)
2307
- print("MultipartFiles:", resp.multipart_files)
2308
- print("Urls:", resp.urls)
2309
-
2310
- # ===== MULTIPART ONLY IF SERVER SENT PARTS =====
2311
- if resp.multipart_files and len(resp.multipart_files) > 0:
2312
-
2313
- mp = resp.multipart_files[0]
2314
-
2315
- full_virtual_path = (
2316
- header.path.rstrip("/") + "/" + mp.file_path.lstrip("/")
2317
- )
2318
-
2319
- with open(slide_path, "rb") as stream:
2320
- await client.upload_multipart_file_to_s3(
2321
- mp,
2322
- full_virtual_path,
2323
- stream,
2324
- session_id,
2325
- progress_callback=progress_callback
2326
- )
2327
-
2328
- # ===== SINGLE PUT / AZURE / FILESYSTEM =====
2329
- else:
2330
-
2331
- upload_url = resp.urls[0] if resp.urls else None
2332
-
2333
- with open(slide_path, "rb") as stream:
2334
- await client.upload_file(
2335
- resp.id,
2336
- resp.upload_type,
2337
- upload_url,
2338
- file_name,
2339
- stream,
2340
- session_id,
2341
- total_bytes=file_size,
2342
- progress_callback=progress_callback
2343
- )
2344
-
2345
- # ===== STATUS =====
2346
- for _ in range(12):
2347
- st = await client.get_upload_status(resp.id, session_id)
2348
-
2349
- if '"Complete": true' in st or '"Complete" : true' in st:
2350
- return True, None
2351
-
2352
- await asyncio.sleep(10)
2353
-
2354
- return False, "Upload not completed"
2478
+ if not os.path.isfile(slide_path):
2479
+ return False, f"File not found: {slide_path}"
2355
2480
 
2356
- except Exception as e:
2357
- return False, str(e)
2481
+ normalized_upload_directory = _normalize_upload_directory(upload_directory)
2482
+ single_file = [{
2483
+ "local_path": slide_path,
2484
+ "relative_path": os.path.basename(slide_path),
2485
+ "is_main": True,
2486
+ "size": os.stat(slide_path).st_size
2487
+ }]
2488
+
2489
+ client = PmaCoreClient(pma_core_url)
2490
+ return await _upload_package_files(
2491
+ client=client,
2492
+ session_id=session_id,
2493
+ upload_directory=normalized_upload_directory,
2494
+ package_files=single_file,
2495
+ progress_callback=progress_callback,
2496
+ verify=verify,
2497
+ )
2358
2498
 
2359
2499
 
2360
2500
  # callback for large files upload
@@ -1,3 +1,5 @@
1
+ from __future__ import annotations
2
+
1
3
  import asyncio
2
4
 
3
5
  import requests
@@ -17,6 +19,9 @@ class TLS12HttpAdapter(HTTPAdapter):
17
19
  ctx = ssl.create_default_context()
18
20
  ctx.minimum_version = ssl.TLSVersion.TLSv1_2
19
21
  ctx.maximum_version = ssl.TLSVersion.TLSv1_2
22
+ # Requests may switch cert requirements to CERT_NONE when verify=False.
23
+ # Keep check_hostname disabled on the context to avoid SSL runtime errors.
24
+ ctx.check_hostname = False
20
25
 
21
26
  self.poolmanager = PoolManager(
22
27
  num_pools=connections,
@@ -26,6 +31,13 @@ class TLS12HttpAdapter(HTTPAdapter):
26
31
  **pool_kwargs
27
32
  )
28
33
 
34
+ def cert_verify(self, conn, url, verify, cert):
35
+ super().cert_verify(conn, url, verify, cert)
36
+ # urllib3 expects assert_hostname to be False/None/string, not bool True.
37
+ # False disables hostname checks (verify=False); None means default behavior.
38
+ if hasattr(conn, "assert_hostname"):
39
+ conn.assert_hostname = False if not verify else None
40
+
29
41
  class _ProgressStream:
30
42
  def __init__(self, raw, total_bytes: int, callback: UploadProgressCallback | None):
31
43
  self.raw = raw
@@ -48,6 +60,12 @@ class _ProgressStream:
48
60
  def tell(self):
49
61
  return self.raw.tell()
50
62
 
63
+ def __len__(self):
64
+ return self.total_bytes
65
+
66
+ def close(self):
67
+ return self.raw.close()
68
+
51
69
 
52
70
  # Model classes
53
71
  class UploadFileModel:
@@ -200,12 +218,17 @@ class PmaCoreClient:
200
218
  response.raise_for_status()
201
219
  return UploadResponse(**response.json())
202
220
 
203
- async def upload_file(self, upload_id: int, upload_type: Optional[str], upload_url: str,
221
+ async def upload_file(self, upload_id: int, upload_type: Optional[str], upload_url: Optional[str],
204
222
  relative_path: str, stream: IO, session_id: str,
205
223
  total_bytes: int | None = None,
206
- progress_callback: UploadProgressCallback | None = None):
207
-
208
- if upload_type == "Azure" or upload_type == 2:
224
+ progress_callback: UploadProgressCallback | None = None,
225
+ method: str = "PUT",
226
+ verify: bool = True):
227
+
228
+ normalized_upload_type = str(upload_type).lower() if upload_type is not None else ""
229
+ if normalized_upload_type in ("azure", "2"):
230
+ if not upload_url:
231
+ raise ValueError("Azure upload requires an upload URL")
209
232
  await self.upload_file_to_azure(
210
233
  upload_url,
211
234
  stream,
@@ -221,32 +244,29 @@ class PmaCoreClient:
221
244
  wrapped = _ProgressStream(stream, total_bytes, progress_callback)
222
245
 
223
246
  if upload_to_presigned:
224
- data = wrapped.read()
225
- print("========== DEBUG PRESIGNED ==========")
226
- print("upload_url RAW =", upload_url)
227
- print("type(upload_url) =", type(upload_url))
228
- print("====================================")
229
-
230
- headers = {
231
- "Content-Length": str(len(data))
232
- }
233
-
234
- import urllib3
235
-
236
- http = urllib3.PoolManager()
237
-
238
- response = await asyncio.to_thread(
239
- http.request,
240
- "PUT",
241
- upload_url,
242
- body=data,
243
- headers=headers,
244
- preload_content=False
245
- )
246
-
247
- if response.status >= 300:
248
- raise Exception(f"S3 upload failed: {response.status}")
247
+ resolved_method = (method or "PUT").upper()
248
+ headers = {}
249
+ if total_bytes is not None:
250
+ headers["Content-Length"] = str(total_bytes)
251
+
252
+ if resolved_method == "POST":
253
+ files = {"file": (relative_path.split("/")[-1], wrapped, "application/octet-stream")}
254
+ response = await asyncio.to_thread(
255
+ self.session.post,
256
+ upload_url,
257
+ files=files,
258
+ verify=verify
259
+ )
260
+ else:
261
+ response = await asyncio.to_thread(
262
+ self.session.put,
263
+ upload_url,
264
+ data=wrapped,
265
+ headers=headers,
266
+ verify=verify
267
+ )
249
268
 
269
+ response.raise_for_status()
250
270
  return
251
271
 
252
272
  url = f"{self.server_url}transfer/Upload/{upload_id}"
@@ -254,10 +274,11 @@ class PmaCoreClient:
254
274
 
255
275
  files = {"file": (relative_path.split("/")[-1], wrapped, "application/octet-stream")}
256
276
  response = await asyncio.to_thread(
257
- requests.post,
277
+ self.session.post,
258
278
  url,
259
279
  params=params,
260
- files=files
280
+ files=files,
281
+ verify=verify
261
282
  )
262
283
  response.raise_for_status()
263
284
 
@@ -340,7 +361,7 @@ class PmaCoreClient:
340
361
  total_bytes: int | None = None,
341
362
  progress_callback: UploadProgressCallback | None = None,
342
363
  ):
343
- block_size = 4 * 1024 * 1024
364
+ block_size = 1024 * 1024 * 1024
344
365
  block_id = 0
345
366
  sent_total = 0
346
367
  safe_base = upload_url.replace(" ", "%20")
@@ -439,8 +460,18 @@ class PmaCoreClient:
439
460
 
440
461
  response.raise_for_status()
441
462
 
442
- etag = response.headers.get("ETag", "") or ""
443
- etag = etag.strip().strip('"')
463
+ raw_etag = response.headers.get("ETag")
464
+ if raw_etag is None:
465
+ raise ValueError(
466
+ f"Multipart part upload succeeded but no ETag header was returned for part {part.PartNumber}."
467
+ )
468
+
469
+ # Some proxies/S3-compatible layers may surface non-string header values.
470
+ etag = str(raw_etag).strip().strip('"')
471
+ if not etag:
472
+ raise ValueError(
473
+ f"Multipart part upload succeeded but ETag value was empty for part {part.PartNumber}."
474
+ )
444
475
 
445
476
  part_etags.append(
446
477
  PartETagModel(
@@ -0,0 +1 @@
1
+ __version__ = '2.0.0.186'
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: pma_python
3
- Version: 2.0.0.184
3
+ Version: 2.0.0.186
4
4
  Summary: Universal viewing of digital microscopy, whole slide imaging and digital pathology data
5
5
  Home-page: https://github.com/pathomation/pma_python
6
6
  Author: Pathomation
@@ -13,4 +13,5 @@ pma_python.egg-info/SOURCES.txt
13
13
  pma_python.egg-info/dependency_links.txt
14
14
  pma_python.egg-info/not-zip-safe
15
15
  pma_python.egg-info/requires.txt
16
- pma_python.egg-info/top_level.txt
16
+ pma_python.egg-info/top_level.txt
17
+ tests/test_slide_package.py
@@ -0,0 +1,143 @@
1
+ """Tests for _collect_slide_package_files: multi-file slides must be uploaded with their image data."""
2
+ import asyncio
3
+ import os
4
+ import shutil
5
+ import tempfile
6
+ import unittest
7
+ from unittest import mock
8
+
9
+ from pma_python import core
10
+
11
+ REAL_PMA_START_HELPER = core._pma_start_files_for_slide
12
+
13
+
14
+ def write(path, size):
15
+ os.makedirs(os.path.dirname(path), exist_ok=True)
16
+ with open(path, "wb") as f:
17
+ f.write(b"x" * size)
18
+ return path
19
+
20
+
21
+ class CollectSlidePackageFiles(unittest.TestCase):
22
+ def setUp(self):
23
+ self.dir = tempfile.mkdtemp()
24
+ # by default PMA.start is not running
25
+ p = mock.patch.object(core, "_pma_start_files_for_slide", return_value=[])
26
+ p.start()
27
+ self.addCleanup(p.stop)
28
+
29
+ def tearDown(self):
30
+ shutil.rmtree(self.dir, ignore_errors=True)
31
+
32
+ def rel(self, files):
33
+ return [f["relative_path"] for f in files]
34
+
35
+ def test_olympus_vsi_underscore_folder(self):
36
+ vsi = write(os.path.join(self.dir, "VL1_Name, A_1AH&E.vsi"), 100)
37
+ write(os.path.join(self.dir, "_VL1_Name, A_1AH&E_", "stack1", "frame_t.ets"), 5000)
38
+ write(os.path.join(self.dir, "_VL1_Name, A_1AH&E_", "stack10001", "frame_t.ets"), 300)
39
+ files = core._collect_slide_package_files(vsi)
40
+ self.assertEqual(self.rel(files), [
41
+ "VL1_Name, A_1AH&E.vsi",
42
+ "_VL1_Name, A_1AH&E_/stack1/frame_t.ets",
43
+ "_VL1_Name, A_1AH&E_/stack10001/frame_t.ets",
44
+ ])
45
+ self.assertTrue(files[0]["is_main"])
46
+ self.assertFalse(any(f["is_main"] for f in files[1:]))
47
+ self.assertEqual(core._get_slide_package_size(vsi), (5400, 5000))
48
+
49
+ def test_mrxs_same_name_folder(self):
50
+ mrxs = write(os.path.join(self.dir, "slide.mrxs"), 10)
51
+ write(os.path.join(self.dir, "slide", "Data0000.dat"), 700)
52
+ write(os.path.join(self.dir, "slide", "Slidedat.ini"), 20)
53
+ self.assertEqual(self.rel(core._collect_slide_package_files(mrxs)),
54
+ ["slide.mrxs", "slide/Data0000.dat", "slide/Slidedat.ini"])
55
+
56
+ def test_single_file_slide(self):
57
+ svs = write(os.path.join(self.dir, "single.svs"), 42)
58
+ write(os.path.join(self.dir, "other.svs"), 42) # unrelated neighbour
59
+ files = core._collect_slide_package_files(svs)
60
+ self.assertEqual(self.rel(files), ["single.svs"])
61
+ self.assertEqual(core._get_slide_package_size(svs), (42, 42))
62
+
63
+ def test_pma_start_list_is_merged_without_duplicates(self):
64
+ vsi = write(os.path.join(self.dir, "a.vsi"), 1)
65
+ ets = write(os.path.join(self.dir, "_a_", "stack1", "frame_t.ets"), 9)
66
+ extra = write(os.path.join(self.dir, "a_extra", "x.bin"), 3) # a convention we do not know
67
+ outside = write(os.path.join(tempfile.mkdtemp(), "elsewhere.bin"), 3)
68
+ with mock.patch.object(core, "_pma_start_files_for_slide", return_value=[vsi, ets, extra, outside]):
69
+ files = core._collect_slide_package_files(vsi)
70
+ self.assertEqual(self.rel(files), ["a.vsi", "_a_/stack1/frame_t.ets", "a_extra/x.bin"])
71
+ shutil.rmtree(os.path.dirname(outside), ignore_errors=True)
72
+
73
+ def test_pma_start_unavailable_is_not_fatal(self):
74
+ vsi = write(os.path.join(self.dir, "b.vsi"), 1)
75
+ write(os.path.join(self.dir, "_b_", "stack1", "frame_t.ets"), 9)
76
+ with mock.patch.object(core, "get_files_for_slide", side_effect=ConnectionError("PMA.start down")), \
77
+ mock.patch.object(core, "_pma_start_files_for_slide", side_effect=REAL_PMA_START_HELPER):
78
+ files = core._collect_slide_package_files(vsi)
79
+ self.assertEqual(self.rel(files), ["b.vsi", "_b_/stack1/frame_t.ets"])
80
+
81
+ def test_missing_file(self):
82
+ with self.assertRaises(ValueError):
83
+ core._collect_slide_package_files(os.path.join(self.dir, "nope.vsi"))
84
+
85
+
86
+ class RealPmaStartHelper(unittest.TestCase):
87
+ def test_returns_empty_list_when_pma_start_errors(self):
88
+ with mock.patch.object(core, "get_files_for_slide", side_effect=ConnectionError("down")):
89
+ self.assertEqual(core._pma_start_files_for_slide("C:/x.vsi"), [])
90
+
91
+ def test_returns_paths_from_pma_start(self):
92
+ with mock.patch.object(core, "get_files_for_slide", return_value={"C:/x.vsi": {}, "C:/_x_/s/f.ets": {}}):
93
+ self.assertEqual(sorted(core._pma_start_files_for_slide("C:/x.vsi")), ["C:/_x_/s/f.ets", "C:/x.vsi"])
94
+
95
+
96
+ class CloudUploadSendsWholeSlide(unittest.TestCase):
97
+ """End to end through upload_file_on_cloud with a fake server: every file must be announced and sent."""
98
+
99
+ def test_vsi_upload_sends_image_data(self):
100
+ d = tempfile.mkdtemp()
101
+ self.addCleanup(shutil.rmtree, d, True)
102
+ vsi = write(os.path.join(d, "VL9_X, Y_1AH&E.vsi"), 100)
103
+ write(os.path.join(d, "_VL9_X, Y_1AH&E_", "stack1", "frame_t.ets"), 5000)
104
+
105
+ sent = []
106
+
107
+ class FakeResp:
108
+ id, upload_type, urls, multipart_files = 7, 2, ["u1", "u2"], []
109
+
110
+ class FakeClient:
111
+ server_url = "https://pma.example/"
112
+
113
+ def __init__(self, url):
114
+ pass
115
+
116
+ async def upload_header(self, header, session_id):
117
+ self.header = header
118
+ sent.append(("header", [(f.path, f.length, f.isMain) for f in header.files]))
119
+ return FakeResp()
120
+
121
+ async def upload_file(self, upload_id, upload_type, url, rel, stream, session_id, **kw):
122
+ sent.append(("file", rel, len(stream.read())))
123
+
124
+ class Ok:
125
+ status_code = 200
126
+
127
+ with mock.patch.object(core, "PmaCoreClient", FakeClient), \
128
+ mock.patch.object(core, "_pma_start_files_for_slide", return_value=[]), \
129
+ mock.patch.object(core.requests, "get", return_value=Ok()), \
130
+ mock.patch.object(core, "get_slide_info", return_value={}):
131
+ ok, err = asyncio.run(core.upload_file_on_cloud(
132
+ pma_core_url="https://pma.example/", session_id="S", slide_path=vsi,
133
+ upload_directory="Slides/Received"))
134
+
135
+ self.assertTrue(ok, err)
136
+ self.assertEqual(sent[0], ("header", [("VL9_X, Y_1AH&E.vsi", 100, True),
137
+ ("_VL9_X, Y_1AH&E_/stack1/frame_t.ets", 5000, False)]))
138
+ self.assertEqual(sent[1:], [("file", "VL9_X, Y_1AH&E.vsi", 100),
139
+ ("file", "_VL9_X, Y_1AH&E_/stack1/frame_t.ets", 5000)])
140
+
141
+
142
+ if __name__ == "__main__":
143
+ unittest.main(verbosity=2)
@@ -1 +0,0 @@
1
- __version__ = '2.0.0.184'
File without changes
File without changes
File without changes