Skip to content

Commit d9240ce

Browse files
Merge pull request #1226 from craftablescience/pr-lxlewis-1065
ENH: allow displays to be loaded from subdirectories of search paths
2 parents ad9c40b + c8c802b commit d9240ce

File tree

10 files changed

+256
-44
lines changed

10 files changed

+256
-44
lines changed

docs/source/tutorials/intro/launcher.rst

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,8 @@ Argument Description
3838
============================ ================================================================================
3939
DISPLAY_FILE (positional) | Loads a display from this file to show once PyDM has started.
4040
-h, --help | Show the PyDM help message and exit.
41+
-r, --recurse | Recursively search for the provided DISPLAY_FILE in the current working
42+
| directory and subfolders.
4143
--homefile FILE | Path to a PyDM file to return to when the home button is clicked in the
4244
| navigation bar. This display remains loaded at all times, and is loaded
4345
| even when a display file is specified separately. If the specified

pydm/application.py

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,10 @@ class PyDMApplication(QApplication):
6060
macros : dict, optional
6161
A dictionary of macro variables to be forwarded to the display class
6262
being loaded.
63+
recursive_display_search : bool, optional
64+
Whether or not to search for a provided display file recursively
65+
in subfolders relative to the current working directory. Does not
66+
apply to the homefile parameter.
6367
use_main_window : bool, optional
6468
If ui_file is note given, this parameter controls whether or not to
6569
create a PyDMMainWindow in the initialization (Default is True).
@@ -83,6 +87,7 @@ def __init__(
8387
hide_status_bar=False,
8488
read_only=False,
8589
macros=None,
90+
recursive_display_search=False,
8691
use_main_window=True,
8792
stylesheet_path=None,
8893
fullscreen=False,
@@ -116,6 +121,7 @@ def __init__(
116121
stylesheet_path=stylesheet_path,
117122
home_file=self.home_file,
118123
macros=macros,
124+
recursive_display_search=recursive_display_search,
119125
command_line_args=command_line_args,
120126
)
121127
if ui_file is not None and self.home_file is not None:
@@ -228,7 +234,7 @@ def new_pydm_process(self, ui_file, macros=None, command_line_args=None):
228234
args.extend(command_line_args)
229235
subprocess.Popen(args, shell=False)
230236

231-
def make_main_window(self, stylesheet_path=None, home_file=None, macros=None, command_line_args=None):
237+
def make_main_window(self, stylesheet_path=None, home_file=None, macros=None, recursive_display_search=False, command_line_args=None):
232238
"""
233239
Instantiate a new PyDMMainWindow, add it to the application's
234240
list of windows. Typically, this function is only called as part
@@ -241,6 +247,7 @@ def make_main_window(self, stylesheet_path=None, home_file=None, macros=None, co
241247
hide_status_bar=self.hide_status_bar,
242248
home_file=home_file,
243249
macros=macros,
250+
recursive_display_search=recursive_display_search,
244251
command_line_args=command_line_args,
245252
)
246253

pydm/main_window.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ def __init__(
3131
hide_status_bar=False,
3232
home_file=None,
3333
macros=None,
34+
recursive_display_search=False,
3435
command_line_args=None,
3536
):
3637
super().__init__(parent)
@@ -39,6 +40,7 @@ def __init__(
3940
self.iconFont = IconFont()
4041
self._display_widget = None
4142
self._showing_file_path_in_title_bar = False
43+
self._recursive_display_search = recursive_display_search
4244

4345
# style sheet change flag
4446
self.isSS_Changed = False
@@ -388,7 +390,7 @@ def open(self, filename, macros=None, args=None, target=None):
388390
curr_display = self.display_widget()
389391
if curr_display:
390392
base_path = os.path.dirname(curr_display.loaded_file())
391-
filename = find_file(filename, base_path=base_path, raise_if_not_found=True)
393+
filename = find_file(filename, base_path=base_path, raise_if_not_found=True, subdir_scan_enabled=self._recursive_display_search, subdir_scan_base_path_only=False)
392394
new_widget = load_file(filename, macros=macros, args=args, target=target)
393395
if new_widget:
394396
if self.home_widget is None:

pydm/tests/utilities/test_utilities.py

Lines changed: 23 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55

66
from qtpy import QtWidgets
77

8-
from ...utilities import find_display_in_path, is_pydm_app, is_qt_designer, log_failures, path_info, which
8+
from ...utilities import find_display_in_path, find_file, is_pydm_app, is_qt_designer, log_failures, path_info, which
99

1010
logger = logging.getLogger(__name__)
1111

@@ -37,7 +37,7 @@ def test_path_info():
3737
def test_find_display_in_path():
3838
temp, file_path = tempfile.mkstemp(suffix=".ui", prefix="display_")
3939
direc, fname, _ = path_info(file_path)
40-
# Try to find the file as is... is should not find it.
40+
# Try to find the file as is... it should not find it.
4141
assert find_display_in_path(fname) is None
4242

4343
# Try to find the file passing the path
@@ -51,6 +51,27 @@ def test_find_display_in_path():
5151
assert disp_path == expected
5252

5353

54+
def test_find_file():
55+
parent_lvl1 = tempfile.mkdtemp()
56+
parent_lvl2 = tempfile.mkdtemp(dir=parent_lvl1)
57+
temp, file_path = tempfile.mkstemp(suffix=".ui", prefix="display_", dir=parent_lvl2)
58+
direc, fname, _ = path_info(file_path)
59+
# Try to find the file as is... it should not find it.
60+
assert find_file(fname) is None
61+
62+
# Try to find the file under base_path
63+
disp_path = find_file(fname, base_path=direc)
64+
assert disp_path == file_path
65+
66+
# Try to find the file under the parent folder without recursion (fail)
67+
disp_path = find_file(fname, base_path=parent_lvl1)
68+
assert disp_path == None
69+
70+
# Try to find the file under the parent folder with recursion (succeed)
71+
disp_path = find_file(fname, base_path=parent_lvl1, subdir_scan_enabled=True)
72+
assert disp_path == file_path
73+
74+
5475
def test_which():
5576
if platform.system() == "Windows":
5677
out = which("ping")

pydm/utilities/__init__.py

Lines changed: 61 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import collections
12
import functools
23
import importlib
34
import importlib.util
@@ -7,6 +8,7 @@
78
import platform
89
import shlex
910
import sys
11+
import time
1012
import types
1113
import uuid
1214
import errno
@@ -228,7 +230,7 @@ def _screen_file_extensions(preferred_extension):
228230
return extensions
229231

230232

231-
def find_file(fname, base_path=None, mode=None, extra_path=None, raise_if_not_found=False):
233+
def find_file(fname, base_path=None, mode=None, raise_if_not_found=False, subdir_scan_enabled=False, subdir_scan_base_path_only=True):
232234
"""
233235
Look for files at the search paths common to PyDM.
234236
@@ -238,7 +240,6 @@ def find_file(fname, base_path=None, mode=None, extra_path=None, raise_if_not_fo
238240
* Qt Designer Path - the path for the current form as reported by the
239241
designer
240242
* The current working directory
241-
* Directories listed in ``extra_path``
242243
* Directories listed in the environment variable ``PYDM_DISPLAYS_PATH``
243244
244245
Parameters
@@ -251,11 +252,15 @@ def find_file(fname, base_path=None, mode=None, extra_path=None, raise_if_not_fo
251252
mode : int
252253
The mode required for the file, defaults to os.F_OK | os.R_OK.
253254
Which ensure that the file exists and we can read it.
254-
extra_path : list
255-
Additional paths to look for file.
256255
raise_if_not_found : bool
257256
Flag which if False will add a check that raises a FileNotFoundError
258257
instead of returning None when the file is not found.
258+
subdir_scan_enabled : bool
259+
If the file cannot be found in the given directories, check
260+
subdirectories. Defaults to False.
261+
subdir_scan_base_path_only : bool
262+
If it is necessary to scan subdirectories for the requested file,
263+
only scan subdirectories of the base_path. Defaults to True.
259264
260265
Returns
261266
-------
@@ -267,23 +272,19 @@ def find_file(fname, base_path=None, mode=None, extra_path=None, raise_if_not_fo
267272
if mode is None:
268273
mode = os.F_OK | os.R_OK
269274

270-
x_path = []
275+
x_path = collections.deque()
271276

272277
if base_path:
273-
x_path.extend([os.path.abspath(base_path)])
278+
base_path = os.path.abspath(base_path)
279+
x_path.append(base_path)
274280

275281
if is_qt_designer():
276282
designer_path = get_designer_current_path()
277283
if designer_path:
278-
x_path.extend([designer_path])
284+
x_path.append(designer_path)
279285

280286
# Current working directory
281-
x_path.extend([os.getcwd()])
282-
if extra_path:
283-
if not isinstance(extra_path, (list, tuple)):
284-
extra_path = [extra_path]
285-
extra_path = [os.path.expanduser(os.path.expandvars(x)) for x in extra_path]
286-
x_path.extend(extra_path)
287+
x_path.append(os.getcwd())
287288

288289
pydm_search_path = os.getenv("PYDM_DISPLAYS_PATH", None)
289290
if pydm_search_path:
@@ -294,15 +295,43 @@ def find_file(fname, base_path=None, mode=None, extra_path=None, raise_if_not_fo
294295

295296
root, ext = os.path.splitext(fname)
296297

297-
# loop through the possible screen file extensions
298-
for e in _screen_file_extensions(ext):
299-
file_path = which(str(root) + str(e), mode=mode, pathext=e, extra_path=x_path)
300-
if file_path is not None:
301-
break # pick the first screen file found
298+
# 3 seconds should be more than generous enough
299+
SUBDIR_SCAN_TIME_LIMIT = 3
300+
start_time = time.perf_counter()
301+
302+
file_path = None
303+
while file_path is None and len(x_path) > 0:
304+
# Loop through the possible screen file extensions
305+
for e in _screen_file_extensions(ext):
306+
file_path = which(str(root) + str(e), mode=mode, pathext=e, extra_path=x_path)
307+
if file_path is not None:
308+
break # pick the first screen file found
309+
310+
if not subdir_scan_enabled or time.perf_counter() - start_time >= SUBDIR_SCAN_TIME_LIMIT:
311+
if not file_path and raise_if_not_found:
312+
raise FileNotFoundError(errno.ENOENT, os.strerror(errno.ENOENT), fname)
313+
break
302314

303-
if raise_if_not_found:
304-
if not file_path:
305-
raise FileNotFoundError(errno.ENOENT, os.strerror(errno.ENOENT), fname)
315+
# Only search recursively under base path
316+
if subdir_scan_base_path_only:
317+
if base_path is None or len(base_path) == 0:
318+
break
319+
x_path.clear()
320+
x_path.append(os.path.expanduser(os.path.expandvars(base_path)))
321+
# Prevent entering this block again
322+
subdir_scan_base_path_only = False
323+
324+
# This might get large in some situations, but it's the easiest way to do BFS without
325+
# changing too much of the existing logic, and ideally recursion isn't needed
326+
path_count = len(x_path)
327+
for _ in range(path_count):
328+
for subdir in os.listdir(x_path[0]):
329+
if subdir.startswith(".") or subdir.startswith("__pycache__"):
330+
continue
331+
new_path = os.path.join(x_path[0], subdir)
332+
if os.path.isdir(new_path):
333+
x_path.append(new_path)
334+
x_path.popleft()
306335

307336
return file_path
308337

@@ -371,9 +400,6 @@ def _access_check(fn, mode):
371400
return None
372401
path = path.split(os.pathsep)
373402

374-
if extra_path is not None:
375-
path = extra_path + path
376-
377403
if sys.platform == "win32":
378404
# The current directory takes precedence on Windows.
379405
if os.curdir not in path:
@@ -397,14 +423,17 @@ def _access_check(fn, mode):
397423
files = [cmd]
398424

399425
seen = set()
400-
for dir_ in path:
401-
normdir = os.path.normcase(dir_)
402-
if normdir not in seen:
403-
seen.add(normdir)
404-
for thefile in files:
405-
name = os.path.join(dir_, thefile)
406-
if _access_check(name, mode):
407-
return name
426+
for paths in extra_path, path:
427+
if paths is None:
428+
continue
429+
for dir_ in paths:
430+
normdir = os.path.normcase(dir_)
431+
if normdir not in seen:
432+
seen.add(normdir)
433+
for thefile in files:
434+
name = os.path.join(dir_, thefile)
435+
if _access_check(name, mode):
436+
return name
408437
return None
409438

410439

pydm/widgets/drawing.py

Lines changed: 29 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -895,6 +895,7 @@ def __init__(self, parent=None, init_channel=None, filename=""):
895895
self._pixmap.fill(self.null_color)
896896
self._aspect_ratio_mode = Qt.KeepAspectRatio
897897
self._movie = None
898+
self._recursive_image_search = False
898899
self._file = None
899900
# Make sure we don't set a non-existent file
900901
if filename:
@@ -924,11 +925,37 @@ def designer_form_saved(self, filename): # pragma: no cover
924925
def reload_image(self) -> None:
925926
self.filename = self._file
926927

928+
@Property(bool)
929+
def recursiveImageSearch(self) -> bool:
930+
"""
931+
Whether or not to search for a provided image file recursively
932+
in subfolders relative to the location of this display.
933+
934+
Returns
935+
-------
936+
bool
937+
If recursive search is enabled.
938+
"""
939+
return self._recursive_image_search
940+
941+
@recursiveImageSearch.setter
942+
def recursiveImageSearch(self, new_value) -> None:
943+
"""
944+
Set whether or not to search for a provided image file recursively
945+
in subfolders relative to the location of this image.
946+
947+
Parameters
948+
----------
949+
new_value
950+
If recursive search should be enabled.
951+
"""
952+
self._recursive_image_search = new_value
953+
927954
@Property(str)
928955
def filename(self) -> str:
929956
"""
930957
The filename of the image to be displayed.
931-
This can be an absolute or relative path to the display file.
958+
This can be an absolute or relative path to the image file.
932959
933960
Returns
934961
-------
@@ -961,7 +988,7 @@ def filename(self, new_file) -> None:
961988
base_path = None
962989
if parent_display:
963990
base_path = os.path.dirname(parent_display.loaded_file())
964-
abs_path = find_file(abs_path, base_path=base_path)
991+
abs_path = find_file(abs_path, base_path=base_path, subdir_scan_enabled=self._recursive_image_search)
965992
if not abs_path:
966993
logger.error("Unable to find full filepath for %s", self._file)
967994
return

0 commit comments

Comments
 (0)