ipyfilechooser/ipyfilechooser/filechooser.py

630 wiersze
22 KiB
Python

import os
import warnings
from typing import Optional, Sequence, Mapping, Callable
from ipywidgets import Dropdown, Text, Select, Button, HTML
from ipywidgets import Layout, GridBox, Box, HBox, VBox, ValueWidget
from .errors import ParentPathError, InvalidFileNameError
from .utils import get_subpaths, get_dir_contents, match_item, strip_parent_path
from .utils import is_valid_filename, get_drive_letters, normalize_path, has_parent_path
class FileChooser(VBox, ValueWidget):
"""FileChooser class."""
_LBL_TEMPLATE = '<span style="color:{1};">{0}</span>'
_LBL_NOFILE = 'No selection'
def __init__(
self,
path: str = os.getcwd(),
filename: str = '',
title: str = '',
select_desc: str = 'Select',
change_desc: str = 'Change',
show_hidden: bool = False,
select_default: bool = False,
dir_icon: Optional[str] = '\U0001F4C1 ',
dir_icon_append: bool = False,
show_only_dirs: bool = False,
filter_pattern: Optional[Sequence[str]] = None,
sandbox_path: Optional[str] = None,
layout: Layout = Layout(width='500px'),
**kwargs):
"""Initialize FileChooser object."""
# Check if path and sandbox_path align
if sandbox_path and not has_parent_path(normalize_path(path), normalize_path(sandbox_path)):
raise ParentPathError(path, sandbox_path)
# Verify the filename is valid
if not is_valid_filename(filename):
raise InvalidFileNameError(filename)
self._default_path = normalize_path(path)
self._default_filename = filename
self._selected_path: Optional[str] = None
self._selected_filename: Optional[str] = None
self._show_hidden = show_hidden
self._select_desc = select_desc
self._change_desc = change_desc
self._select_default = select_default
self._dir_icon = dir_icon
self._dir_icon_append = dir_icon_append
self._show_only_dirs = show_only_dirs
self._filter_pattern = filter_pattern
self._sandbox_path = normalize_path(sandbox_path) if sandbox_path is not None else None
self._callback: Optional[Callable] = None
# Widgets
self._pathlist = Dropdown(
description="",
layout=Layout(
width='auto',
grid_area='pathlist'
)
)
self._filename = Text(
placeholder='output filename',
layout=Layout(
width='auto',
grid_area='filename',
display=(None, "none")[self._show_only_dirs]
),
disabled=self._show_only_dirs
)
self._dircontent = Select(
rows=8,
layout=Layout(
width='auto',
grid_area='dircontent'
)
)
self._cancel = Button(
description='Cancel',
layout=Layout(
min_width='6em',
width='6em',
display='none'
)
)
self._select = Button(
description=self._select_desc,
layout=Layout(
min_width='6em',
width='6em'
)
)
self._title = HTML(
value=title
)
if title == '':
self._title.layout.display = 'none'
# Widget observe handlers
self._pathlist.observe(self._on_pathlist_select, names='value')
self._dircontent.observe(self._on_dircontent_select, names='value')
self._filename.observe(self._on_filename_change, names='value')
self._select.on_click(self._on_select_click)
self._cancel.on_click(self._on_cancel_click)
# Selected file label
self._label = HTML(
value=self._LBL_TEMPLATE.format(self._LBL_NOFILE, 'black'),
placeholder='',
description='',
layout=Layout(margin='0 0 0 1em')
)
# Layout
self._gb = GridBox(
children=[
self._pathlist,
self._filename,
self._dircontent
],
layout=Layout(
display='none',
width='auto',
grid_gap='0px 0px',
grid_template_rows='auto auto',
grid_template_columns='60% 40%',
grid_template_areas='''
'pathlist {}'
'dircontent dircontent'
'''.format(('filename', 'pathlist')[self._show_only_dirs])
)
)
buttonbar = HBox(
children=[
self._select,
self._cancel,
Box([self._label], layout=Layout(overflow='auto'))
],
layout=Layout(width='auto')
)
# Call setter to set initial form values
self._set_form_values(self._default_path, self._default_filename)
# Use the defaults as the selected values
if self._select_default:
self._apply_selection()
# Call VBox super class __init__
super().__init__(
children=[
self._title,
self._gb,
buttonbar
],
layout=layout,
**kwargs
)
def _set_form_values(self, path: str, filename: str) -> None:
"""Set the form values."""
# Check if the path falls inside the configured sandbox path
if self._sandbox_path and not has_parent_path(path, self._sandbox_path):
raise ParentPathError(path, self._sandbox_path)
# Disable triggers to prevent selecting an entry in the Select
# box from automatically triggering a new event.
self._pathlist.unobserve(self._on_pathlist_select, names='value')
self._dircontent.unobserve(self._on_dircontent_select, names='value')
self._filename.unobserve(self._on_filename_change, names='value')
try:
# Fail early if the folder can not be read
_ = os.listdir(path)
# In folder only mode zero out the filename
if self._show_only_dirs:
filename = ''
# Set form values
restricted_path = self._restrict_path(path)
subpaths = get_subpaths(restricted_path)
if os.path.splitdrive(subpaths[-1])[0]:
# Add missing Windows drive letters
drives = get_drive_letters()
subpaths.extend(list(set(drives) - set(subpaths)))
self._pathlist.options = subpaths
self._pathlist.value = restricted_path
self._filename.value = filename
# file/folder real names
dircontent_real_names = get_dir_contents(
path,
show_hidden=self._show_hidden,
show_only_dirs=self._show_only_dirs,
dir_icon=None,
filter_pattern=self._filter_pattern,
top_path=self._sandbox_path
)
# file/folder display names
dircontent_display_names = get_dir_contents(
path,
show_hidden=self._show_hidden,
show_only_dirs=self._show_only_dirs,
dir_icon=self._dir_icon,
dir_icon_append=self._dir_icon_append,
filter_pattern=self._filter_pattern,
top_path=self._sandbox_path
)
# Dict to map real names to display names
self._map_name_to_disp = {
real_name: disp_name
for real_name, disp_name in zip(
dircontent_real_names,
dircontent_display_names
)
}
# Dict to map display names to real names
self._map_disp_to_name = {
disp_name: real_name
for real_name, disp_name in self._map_name_to_disp.items()
}
# Set _dircontent form value to display names
self._dircontent.options = dircontent_display_names
# If the value in the filename Text box equals a value in the
# Select box and the entry is a file then select the entry.
if ((filename in dircontent_real_names) and os.path.isfile(os.path.join(path, filename))):
self._dircontent.value = self._map_name_to_disp[filename]
else:
self._dircontent.value = None
# Update the state of the select button
if self._gb.layout.display is None:
# Disable the select button if path and filename
# - equal an existing folder in the current view
# - contains an invalid character sequence
# - equal the already selected values
# - don't match the provided filter pattern(s)
check1 = filename in dircontent_real_names
check2 = os.path.isdir(os.path.join(path, filename))
check3 = not is_valid_filename(filename)
check4 = False
check5 = False
# Only check selected if selected is set
if ((self._selected_path is not None) and (self._selected_filename is not None)):
selected = os.path.join(self._selected_path, self._selected_filename)
check4 = os.path.join(path, filename) == selected
# Ensure only allowed extensions are used
if self._filter_pattern:
check5 = not match_item(filename, self._filter_pattern)
if (check1 and check2) or check3 or check4 or check5:
self._select.disabled = True
else:
self._select.disabled = False
except PermissionError:
# Deselect the unreadable folder and generate a warning
self._dircontent.value = None
warnings.warn(f'Permission denied for {path}', RuntimeWarning)
# Reenable triggers
self._pathlist.observe(self._on_pathlist_select, names='value')
self._dircontent.observe(self._on_dircontent_select, names='value')
self._filename.observe(self._on_filename_change, names='value')
def _on_pathlist_select(self, change: Mapping[str, str]) -> None:
"""Handle selecting a path entry."""
self._set_form_values(self._expand_path(change['new']), self._filename.value)
def _on_dircontent_select(self, change: Mapping[str, str]) -> None:
"""Handle selecting a folder entry."""
new_path = os.path.realpath(os.path.join(
self._expand_path(self._pathlist.value),
self._map_disp_to_name[change['new']]
))
# Check if folder or file
if os.path.isdir(new_path):
path = new_path
filename = self._filename.value
else:
path = self._expand_path(self._pathlist.value)
filename = self._map_disp_to_name[change['new']]
self._set_form_values(path, filename)
def _on_filename_change(self, change: Mapping[str, str]) -> None:
"""Handle filename field changes."""
self._set_form_values(self._expand_path(self._pathlist.value), change['new'])
def _on_select_click(self, _b) -> None:
"""Handle select button clicks."""
if self._gb.layout.display == 'none':
# If not shown, open the dialog
self._show_dialog()
else:
# If shown, close the dialog and apply the selection
self._apply_selection()
# Execute callback function
if self._callback is not None:
try:
self._callback(self)
except TypeError:
# Support previous behaviour of not passing self
self._callback()
def _show_dialog(self) -> None:
"""Show the dialog."""
# Show dialog and cancel button
self._gb.layout.display = None
self._cancel.layout.display = None
# Show the form with the correct path and filename
if ((self._selected_path is not None) and (self._selected_filename is not None)):
path = self._selected_path
filename = self._selected_filename
else:
path = self._default_path
filename = self._default_filename
self._set_form_values(path, filename)
def _apply_selection(self) -> None:
"""Close the dialog and apply the selection."""
self._selected_path = self._expand_path(self._pathlist.value)
self._selected_filename = self._filename.value
if ((self._selected_path is not None) and (self._selected_filename is not None)):
selected = os.path.join(self._selected_path, self._selected_filename)
self._gb.layout.display = 'none'
self._cancel.layout.display = 'none'
self._select.description = self._change_desc
self._select.disabled = False
if os.path.isfile(selected):
self._label.value = self._LBL_TEMPLATE.format(self._restrict_path(selected), 'orange')
else:
self._label.value = self._LBL_TEMPLATE.format(self._restrict_path(selected), 'green')
def _on_cancel_click(self, _b) -> None:
"""Handle cancel button clicks."""
self._gb.layout.display = 'none'
self._cancel.layout.display = 'none'
self._select.disabled = False
def _expand_path(self, path) -> str:
"""Calculate the full path using the sandbox path."""
if self._sandbox_path:
path = os.path.join(self._sandbox_path, path.lstrip(os.sep))
return path
def _restrict_path(self, path) -> str:
"""Calculate the sandboxed path using the sandbox path."""
if self._sandbox_path == os.sep:
pass
elif self._sandbox_path == path:
path = os.sep
elif self._sandbox_path:
if os.path.splitdrive(self._sandbox_path)[0] and len(self._sandbox_path) == 3:
# If the value is 'c:\\', strip 'c:' so we retain the leading os.sep char
path = strip_parent_path(path, os.path.splitdrive(self._sandbox_path)[0])
else:
path = strip_parent_path(path, self._sandbox_path)
return path
def reset(self, path: Optional[str] = None, filename: Optional[str] = None) -> None:
"""Reset the form to the default path and filename."""
# Check if path and sandbox_path align
if path is not None and self._sandbox_path and not has_parent_path(normalize_path(path), self._sandbox_path):
raise ParentPathError(path, self._sandbox_path)
# Verify the filename is valid
if filename is not None and not is_valid_filename(filename):
raise InvalidFileNameError(filename)
# Remove selection
self._selected_path = None
self._selected_filename = None
# Hide dialog and cancel button
self._gb.layout.display = 'none'
self._cancel.layout.display = 'none'
# Reset select button and label
self._select.description = self._select_desc
self._select.disabled = False
self._label.value = self._LBL_TEMPLATE.format(self._LBL_NOFILE, 'black')
if path is not None:
self._default_path = normalize_path(path)
if filename is not None:
self._default_filename = filename
self._set_form_values(self._default_path, self._default_filename)
# Use the defaults as the selected values
if self._select_default:
self._apply_selection()
def refresh(self) -> None:
"""Re-render the form."""
self._set_form_values(self._expand_path(self._pathlist.value), self._filename.value)
@property
def show_hidden(self) -> bool:
"""Get _show_hidden value."""
return self._show_hidden
@show_hidden.setter
def show_hidden(self, hidden: bool) -> None:
"""Set _show_hidden value."""
self._show_hidden = hidden
self.refresh()
@property
def dir_icon(self) -> Optional[str]:
"""Get dir icon value."""
return self._dir_icon
@dir_icon.setter
def dir_icon(self, dir_icon: Optional[str]) -> None:
"""Set dir icon value."""
self._dir_icon = dir_icon
self.refresh()
@property
def dir_icon_append(self) -> bool:
"""Get dir icon value."""
return self._dir_icon_append
@dir_icon_append.setter
def dir_icon_append(self, dir_icon_append: bool) -> None:
"""Prepend or append the dir icon."""
self._dir_icon_append = dir_icon_append
self.refresh()
@property
def rows(self) -> int:
"""Get current number of rows."""
return self._dircontent.rows
@rows.setter
def rows(self, rows: int) -> None:
"""Set number of rows."""
self._dircontent.rows = rows
@property
def title(self) -> str:
"""Get the title."""
return self._title.value
@title.setter
def title(self, title: str) -> None:
"""Set the title."""
self._title.value = title
if title == '':
self._title.layout.display = 'none'
else:
self._title.layout.display = None
@property
def default(self) -> str:
"""Get the default value."""
return os.path.join(self._default_path, self._default_filename)
@property
def default_path(self) -> str:
"""Get the default_path value."""
return self._default_path
@default_path.setter
def default_path(self, path: str) -> None:
"""Set the default_path."""
# Check if path and sandbox_path align
if self._sandbox_path and not has_parent_path(normalize_path(path), self._sandbox_path):
raise ParentPathError(path, self._sandbox_path)
self._default_path = normalize_path(path)
self._set_form_values(self._default_path, self._filename.value)
@property
def default_filename(self) -> str:
"""Get the default_filename value."""
return self._default_filename
@default_filename.setter
def default_filename(self, filename: str) -> None:
"""Set the default_filename."""
# Verify the filename is valid
if not is_valid_filename(filename):
raise InvalidFileNameError(filename)
self._default_filename = filename
self._set_form_values(self._expand_path(self._pathlist.value), self._default_filename)
@property
def sandbox_path(self) -> Optional[str]:
"""Get the sandbox_path."""
return self._sandbox_path
@sandbox_path.setter
def sandbox_path(self, sandbox_path: str) -> None:
"""Set the sandbox_path."""
# Check if path and sandbox_path align
if sandbox_path and not has_parent_path(self._default_path, normalize_path(sandbox_path)):
raise ParentPathError(self._default_path, sandbox_path)
self._sandbox_path = normalize_path(sandbox_path) if sandbox_path is not None else None
# Reset the dialog
self.reset()
@property
def show_only_dirs(self) -> bool:
"""Get show_only_dirs property value."""
return self._show_only_dirs
@show_only_dirs.setter
def show_only_dirs(self, show_only_dirs: bool) -> None:
"""Set show_only_dirs property value."""
self._show_only_dirs = show_only_dirs
# Update widget layout
self._filename.disabled = self._show_only_dirs
self._filename.layout.display = (None, "none")[self._show_only_dirs]
self._gb.layout.children = [
self._pathlist,
self._dircontent
]
if not self._show_only_dirs:
self._gb.layout.children.insert(1, self._filename)
self._gb.layout.grid_template_areas = '''
'pathlist {}'
'dircontent dircontent'
'''.format(('filename', 'pathlist')[self._show_only_dirs])
# Reset the dialog
self.reset()
@property
def filter_pattern(self) -> Optional[Sequence[str]]:
"""Get file name filter pattern."""
return self._filter_pattern
@filter_pattern.setter
def filter_pattern(self, filter_pattern: Optional[Sequence[str]]) -> None:
"""Set file name filter pattern."""
self._filter_pattern = filter_pattern
self.refresh()
@property
def value(self) -> Optional[str]:
"""Get selected value."""
return self.selected
@property
def selected(self) -> Optional[str]:
"""Get selected value."""
selected = None
if ((self._selected_path is not None) and (self._selected_filename is not None)):
selected = os.path.join(self._selected_path, self._selected_filename)
return selected
@property
def selected_path(self) -> Optional[str]:
"""Get selected_path value."""
return self._selected_path
@property
def selected_filename(self) -> Optional[str]:
"""Get the selected_filename."""
return self._selected_filename
def __repr__(self) -> str:
"""Build string representation."""
properties = f"path='{self._default_path}'"
properties += f", filename='{self._default_filename}'"
properties += f", title='{self._title.value}'"
properties += f", show_hidden={self._show_hidden}"
properties += f", select_desc='{self._select_desc}'"
properties += f", change_desc='{self._change_desc}'"
properties += f", select_default={self._select_default}"
properties += f", show_only_dirs={self._show_only_dirs}"
properties += f", dir_icon_append={self._dir_icon_append}"
if self._sandbox_path is not None:
properties += f", sandbox_path='{self._sandbox_path}'"
if self._dir_icon:
properties += f", dir_icon='{self._dir_icon}'"
if self._filter_pattern:
if isinstance(self._filter_pattern, str):
properties += f", filter_pattern='{self._filter_pattern}'"
else:
properties += f", filter_pattern={self._filter_pattern}"
return f"{self.__class__.__name__}({properties})"
def register_callback(self, callback: Callable[[Optional['FileChooser']], None]) -> None:
"""Register a callback function."""
self._callback = callback
def get_interact_value(self) -> Optional[str]:
"""Return the value which should be passed to interactive functions."""
return self.selected