pma-python 2.0.0.185__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.185 → pma_python-2.0.0.186}/PKG-INFO +1 -1
  2. {pma_python-2.0.0.185 → pma_python-2.0.0.186}/pma_python/core.py +64 -16
  3. pma_python-2.0.0.186/pma_python/version.py +1 -0
  4. {pma_python-2.0.0.185 → pma_python-2.0.0.186}/pma_python.egg-info/PKG-INFO +1 -1
  5. {pma_python-2.0.0.185 → pma_python-2.0.0.186}/pma_python.egg-info/SOURCES.txt +2 -1
  6. pma_python-2.0.0.186/tests/test_slide_package.py +143 -0
  7. pma_python-2.0.0.185/pma_python/version.py +0 -1
  8. {pma_python-2.0.0.185 → pma_python-2.0.0.186}/README.md +0 -0
  9. {pma_python-2.0.0.185 → pma_python-2.0.0.186}/pma_python/__init__.py +0 -0
  10. {pma_python-2.0.0.185 → pma_python-2.0.0.186}/pma_python/control.py +0 -0
  11. {pma_python-2.0.0.185 → pma_python-2.0.0.186}/pma_python/core_admin.py +0 -0
  12. {pma_python-2.0.0.185 → pma_python-2.0.0.186}/pma_python/pma.py +0 -0
  13. {pma_python-2.0.0.185 → pma_python-2.0.0.186}/pma_python/pma_core_client.py +0 -0
  14. {pma_python-2.0.0.185 → pma_python-2.0.0.186}/pma_python/view.py +0 -0
  15. {pma_python-2.0.0.185 → pma_python-2.0.0.186}/pma_python.egg-info/dependency_links.txt +0 -0
  16. {pma_python-2.0.0.185 → pma_python-2.0.0.186}/pma_python.egg-info/not-zip-safe +0 -0
  17. {pma_python-2.0.0.185 → pma_python-2.0.0.186}/pma_python.egg-info/requires.txt +0 -0
  18. {pma_python-2.0.0.185 → pma_python-2.0.0.186}/pma_python.egg-info/top_level.txt +0 -0
  19. {pma_python-2.0.0.185 → pma_python-2.0.0.186}/setup.cfg +0 -0
  20. {pma_python-2.0.0.185 → 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.185
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
@@ -1935,13 +1935,68 @@ def _is_upload_type(upload_type, *candidates) -> bool:
1935
1935
  return False
1936
1936
 
1937
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
+
1938
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
+ """
1939
1969
  if not os.path.isfile(slide_path):
1940
1970
  raise ValueError(f"File not found: {slide_path}")
1941
1971
 
1972
+ slide_path = os.path.abspath(slide_path)
1973
+ base_dir = os.path.dirname(slide_path)
1942
1974
  main_name = os.path.basename(slide_path)
1943
- child_folder = os.path.splitext(main_name)[0]
1944
- stack_root = os.path.join(os.path.dirname(slide_path), child_folder)
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)
1945
2000
 
1946
2001
  package_files = [{
1947
2002
  "local_path": slide_path,
@@ -1949,20 +2004,13 @@ def _collect_slide_package_files(slide_path: str):
1949
2004
  "is_main": True,
1950
2005
  "size": os.stat(slide_path).st_size
1951
2006
  }]
1952
-
1953
- if os.path.isdir(stack_root):
1954
- for root, dirnames, filenames in os.walk(stack_root):
1955
- dirnames.sort()
1956
- filenames.sort()
1957
- for filename in filenames:
1958
- local_path = os.path.join(root, filename)
1959
- rel_inside_stack = os.path.relpath(local_path, stack_root).replace("\\", "/")
1960
- package_files.append({
1961
- "local_path": local_path,
1962
- "relative_path": f"{child_folder}/{rel_inside_stack}",
1963
- "is_main": False,
1964
- "size": os.stat(local_path).st_size
1965
- })
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
+ })
1966
2014
 
1967
2015
  return package_files
1968
2016
 
@@ -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.185
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.185'
File without changes
File without changes
File without changes