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.
- {pma_python-2.0.0.184 → pma_python-2.0.0.186}/PKG-INFO +1 -1
- {pma_python-2.0.0.184 → pma_python-2.0.0.186}/pma_python/core.py +346 -206
- {pma_python-2.0.0.184 → pma_python-2.0.0.186}/pma_python/pma_core_client.py +65 -34
- pma_python-2.0.0.186/pma_python/version.py +1 -0
- {pma_python-2.0.0.184 → pma_python-2.0.0.186}/pma_python.egg-info/PKG-INFO +1 -1
- {pma_python-2.0.0.184 → pma_python-2.0.0.186}/pma_python.egg-info/SOURCES.txt +2 -1
- pma_python-2.0.0.186/tests/test_slide_package.py +143 -0
- pma_python-2.0.0.184/pma_python/version.py +0 -1
- {pma_python-2.0.0.184 → pma_python-2.0.0.186}/README.md +0 -0
- {pma_python-2.0.0.184 → pma_python-2.0.0.186}/pma_python/__init__.py +0 -0
- {pma_python-2.0.0.184 → pma_python-2.0.0.186}/pma_python/control.py +0 -0
- {pma_python-2.0.0.184 → pma_python-2.0.0.186}/pma_python/core_admin.py +0 -0
- {pma_python-2.0.0.184 → pma_python-2.0.0.186}/pma_python/pma.py +0 -0
- {pma_python-2.0.0.184 → pma_python-2.0.0.186}/pma_python/view.py +0 -0
- {pma_python-2.0.0.184 → pma_python-2.0.0.186}/pma_python.egg-info/dependency_links.txt +0 -0
- {pma_python-2.0.0.184 → pma_python-2.0.0.186}/pma_python.egg-info/not-zip-safe +0 -0
- {pma_python-2.0.0.184 → pma_python-2.0.0.186}/pma_python.egg-info/requires.txt +0 -0
- {pma_python-2.0.0.184 → pma_python-2.0.0.186}/pma_python.egg-info/top_level.txt +0 -0
- {pma_python-2.0.0.184 → pma_python-2.0.0.186}/setup.cfg +0 -0
- {pma_python-2.0.0.184 → pma_python-2.0.0.186}/setup.py +0 -0
|
@@ -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
|
-
|
|
1905
|
-
|
|
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
|
-
|
|
1910
|
-
|
|
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
|
-
|
|
1917
|
-
|
|
1918
|
-
|
|
1919
|
-
|
|
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
|
-
|
|
1922
|
-
|
|
1923
|
-
|
|
1924
|
-
|
|
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
|
|
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
|
-
|
|
1964
|
-
file_name = os.path.basename(slide_path)
|
|
1965
|
-
|
|
2239
|
+
normalized_upload_directory = _normalize_upload_directory(upload_directory)
|
|
1966
2240
|
header = UploadHeaderModel(
|
|
1967
|
-
path=
|
|
2241
|
+
path=normalized_upload_directory,
|
|
1968
2242
|
files=[
|
|
1969
2243
|
UploadFileModel(
|
|
1970
|
-
isMain=
|
|
1971
|
-
length=
|
|
1972
|
-
path=
|
|
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
|
|
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
|
-
#
|
|
1997
|
-
|
|
1998
|
-
|
|
1999
|
-
|
|
2000
|
-
|
|
2001
|
-
|
|
2002
|
-
|
|
2003
|
-
|
|
2004
|
-
|
|
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
|
-
# ===
|
|
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
|
-
# ===
|
|
2423
|
+
# === Cloud Uploader === #
|
|
2161
2424
|
# **************************************#
|
|
2162
|
-
async def
|
|
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
|
-
|
|
2180
|
-
|
|
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
|
-
|
|
2446
|
+
normalized_upload_directory = _normalize_upload_directory(upload_directory)
|
|
2447
|
+
package_files = _collect_slide_package_files(slide_path)
|
|
2221
2448
|
|
|
2222
|
-
|
|
2223
|
-
|
|
2449
|
+
client = PmaCoreClient(pma_core_url)
|
|
2450
|
+
return await _upload_package_files(
|
|
2451
|
+
client=client,
|
|
2224
2452
|
session_id=session_id,
|
|
2225
|
-
|
|
2226
|
-
|
|
2227
|
-
progress_callback=
|
|
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
|
|
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
|
|
2475
|
+
- upload finalization + image info polling
|
|
2279
2476
|
"""
|
|
2280
2477
|
|
|
2281
|
-
|
|
2282
|
-
|
|
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
|
-
|
|
2357
|
-
|
|
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
|
-
|
|
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
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
443
|
-
|
|
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'
|
|
@@ -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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|