diff --git a/build.py b/build.py
index 60255c1a..f9988e72 100644
--- a/build.py
+++ b/build.py
@@ -96,7 +96,7 @@ def release():
settings_path, next_version + snapshot_suffix,
'Bump version for next development iteration'
)
- git('push', '-u', 'origin', 'master')
+ git('push', '-u', 'origin', 'main')
try:
git('push', 'origin', release_tag)
try:
@@ -106,7 +106,7 @@ def release():
' git pull\n'
' git checkout %s\n'
' python build.py release\n'
- ' git checkout master\n\n'
+ ' git checkout main\n\n'
'on the other OSs now, then come back here and do:'
'\n\n'
' python build.py post_release\n'
@@ -117,7 +117,7 @@ def release():
raise
except:
git('revert', '--no-edit', revision_before + '..HEAD' )
- git('push', '-u', 'origin', 'master')
+ git('push', '-u', 'origin', 'main')
revision_before = git('rev-parse', 'HEAD').rstrip()
raise
except:
@@ -149,7 +149,7 @@ def post_release():
create_cloudfront_invalidation(cloudfront_items_to_invalidate)
record_release_on_server()
upload_core_to_github()
- git('checkout', 'master')
+ git('checkout', 'main')
def _prompt_for_next_version(release_version):
next_version = _get_suggested_next_version(release_version)
diff --git a/src/main/python/fman/__init__.py b/src/main/python/fman/__init__.py
index ab1f58d1..eefe7c12 100644
--- a/src/main/python/fman/__init__.py
+++ b/src/main/python/fman/__init__.py
@@ -41,6 +41,8 @@
DATA_DIRECTORY = expanduser('~/Library/Application Support/fman')
elif PLATFORM == 'Linux':
DATA_DIRECTORY = expanduser('~/.config/fman')
+else:
+ raise NotImplementedError('Unsupported platform: %s' % PLATFORM)
class ApplicationCommand:
def __init__(self, window):
diff --git a/src/main/python/fman/fs.py b/src/main/python/fman/fs.py
index 2cf8f00f..d020a0a7 100644
--- a/src/main/python/fman/fs.py
+++ b/src/main/python/fman/fs.py
@@ -146,7 +146,7 @@ def prepare_trash(self, path):
raise self._operation_not_implemented()
return [Task(
'Deleting ' + path.rsplit('/', 1)[-1],
- fn=self.delete, args=(path,), size=1
+ fn=self.move_to_trash, args=(path,), size=1
)]
def touch(self, path):
raise self._operation_not_implemented()
diff --git a/src/main/python/fman/impl/controller.py b/src/main/python/fman/impl/controller.py
index f168ecdb..e6da74e2 100644
--- a/src/main/python/fman/impl/controller.py
+++ b/src/main/python/fman/impl/controller.py
@@ -50,12 +50,12 @@ def handle_nonexistent_shortcut(self, pane_widget, qkeyevent):
pane = self._panes[pane_widget]
key_event = QtKeyEvent(qkeyevent.key(), qkeyevent.modifiers())
return self._nonexistent_shortcut_handler(key_event, pane)
- def on_doubleclicked(self, pane_widget, file_path):
+ def on_doubleclicked(self, pane_widget, file_url):
past_events = self._metrics.past_events[::]
self._metrics.track('DoubleclickedFile')
pane = self._panes[pane_widget]
if not self._usage_helper.on_doubleclicked(pane, past_events):
- pane._broadcast('on_doubleclicked', file_path)
+ pane._broadcast('on_doubleclicked', file_url)
def on_file_renamed(self, pane_widget, *args):
self._metrics.track('RenamedFile')
self._panes[pane_widget]._broadcast('on_name_edited', *args)
@@ -68,7 +68,6 @@ def on_context_menu(self, pane_widget, event, file_under_mouse):
elif event.reason() == QContextMenuEvent.Keyboard:
via = 'Keyboard'
else:
- assert event.reason() == QContextMenuEvent.Other, event.reason()
via = 'Other'
past_events = self._metrics.past_events[::]
self._metrics.track('OpenedContextMenu', {'via': via})
diff --git a/src/main/python/fman/impl/metrics.py b/src/main/python/fman/impl/metrics.py
index d5682162..1d315899 100644
--- a/src/main/python/fman/impl/metrics.py
+++ b/src/main/python/fman/impl/metrics.py
@@ -10,8 +10,11 @@
from urllib.request import urlopen, Request
import json
+import logging
import ssl
+_LOG = logging.getLogger(__name__)
+
class MetricsError(Exception):
pass
@@ -69,14 +72,14 @@ def track(self, event, properties=None):
try:
self._backend.track(self._user, event, data)
except MetricsError:
- pass
+ _LOG.debug('Failed to track event %s', event, exc_info=True)
def update_user(self, **properties):
if not self._enabled:
return
try:
self._backend.update_user(self._user, **properties)
except MetricsError:
- pass
+ _LOG.debug('Failed to update user', exc_info=True)
def _read_json(self):
with open(self._json_path, 'r') as f:
return json.load(f)
@@ -148,6 +151,7 @@ def flush(self, log_file_path):
f.write('\n\n'.join(map(fmt_log, self._logs)))
class AsynchronousMetrics:
+ _SENTINEL = object()
def __init__(self, metrics):
self.past_events = []
self._metrics = metrics
@@ -164,7 +168,14 @@ def track(self, event, properties=None):
self._queue.put(lambda: self._metrics.track(event, properties))
def update_user(self, **properties):
self._queue.put(lambda: self._metrics.update_user(**properties))
+ def shutdown(self, timeout=2):
+ self._queue.put(self._SENTINEL)
+ self._thread.join(timeout)
def _work(self):
while True:
- self._queue.get()()
+ task = self._queue.get()
+ if task is self._SENTINEL:
+ self._queue.task_done()
+ break
+ task()
self._queue.task_done()
\ No newline at end of file
diff --git a/src/main/python/fman/impl/model/model.py b/src/main/python/fman/impl/model/model.py
index f09c1daa..76838992 100644
--- a/src/main/python/fman/impl/model/model.py
+++ b/src/main/python/fman/impl/model/model.py
@@ -10,17 +10,18 @@
from functools import wraps, lru_cache
from PyQt5.QtCore import pyqtSignal, Qt
from PyQt5.QtGui import QIcon, QPixmap
-from threading import Event
+from threading import Event, Lock
from time import time
def transaction(priority, synchronous=False):
def decorator(f):
@wraps(f)
def result(self, *args, **kwargs):
- if self._shutdown:
+ if self._shutdown.is_set():
return
if synchronous:
- assert not is_in_main_thread()
+ if is_in_main_thread():
+ raise RuntimeError('Synchronous transaction must not run in main thread')
has_run = Event()
def task():
f(self, *args, **kwargs)
@@ -66,7 +67,8 @@ def __init__(
self._files = {}
self._file_watcher = FileWatcher(fs, self)
self._worker = Worker()
- self._shutdown = False
+ self._shutdown = Event()
+ self._files_lock = Lock()
def start(self, callback):
self._worker.start()
self._init(callback)
@@ -78,7 +80,7 @@ def _init(self, callback):
except FileNotFoundError:
self.location_disappeared.emit(self._location)
return
- while not self._shutdown:
+ while not self._shutdown.is_set():
try:
file_name = next(file_names)
except FileNotFoundError:
@@ -94,11 +96,10 @@ def _init(self, callback):
continue
files.append(file_)
else:
- assert self._shutdown
return
preloaded_files = self._sorted(self._filter(files))
for i in range(min(self._num_rows_to_preload, len(preloaded_files))):
- if self._shutdown:
+ if self._shutdown.is_set():
return
try:
preloaded_files[i] = self._load_file(preloaded_files[i].url)
@@ -151,14 +152,18 @@ def _on_rows_inited(self, rows, preloaded_rows, callback):
# comment in #_on_rows_inited_main(...).
self._on_rows_inited_main(rows, preloaded_rows, callback)
else:
- callback()
+ self._on_empty_rows_inited(callback)
+ @run_in_main_thread
+ def _on_empty_rows_inited(self, callback):
+ callback()
@run_in_main_thread
def _on_rows_inited_main(self, rows, preloaded_rows, callback):
- self._files = {
- row.url: row for row in rows
- }
- for preloaded_row in preloaded_rows:
- self._files[preloaded_row.url] = preloaded_row
+ with self._files_lock:
+ self._files = {
+ row.url: row for row in rows
+ }
+ for preloaded_row in preloaded_rows:
+ self._files[preloaded_row.url] = preloaded_row
# We have a transaction_ended listener that ensures we have a cursor.
# It is used for example when a filter's conditions were relaxed, so
# there are now visible files when previously there were none. However,
@@ -176,7 +181,8 @@ def _on_rows_inited_main(self, rows, preloaded_rows, callback):
def row_is_loaded(self, rownum):
return self._rows[rownum].is_loaded
def load_rows(self, rownums, callback=None):
- assert is_in_main_thread()
+ if not is_in_main_thread():
+ raise RuntimeError('load_rows must be called from main thread')
urls = [self._rows[i].url for i in rownums]
self._load_files_async(urls, callback)
@transaction(priority=2)
@@ -186,7 +192,7 @@ def _load_files(self, urls, callback=None):
files = []
disappeared = []
for url in urls:
- if self._shutdown:
+ if self._shutdown.is_set():
return
try:
files.append(self._load_file(url))
@@ -217,6 +223,8 @@ def _record_files_main(self, files, disappeared=None):
"""
if disappeared is None:
disappeared = []
+ if self._shutdown.is_set():
+ return
self._begin_transaction()
RecordFiles(
files, disappeared, self._files,
@@ -226,15 +234,20 @@ def _record_files_main(self, files, disappeared=None):
@transaction(priority=3)
def sort(self, column, order=Qt.AscendingOrder):
ascending = order == Qt.AscendingOrder
- for i, row in enumerate(self._rows):
+ updated = {}
+ for row in list(self._rows):
if not self._sort_value_is_loaded(row, column, ascending):
new_row = self._load_sort_value(row, column, ascending)
- # Here, we violate the constraint that data only be changed in
- # the main thread. But! The data we are changing here is not
- # "visible" outside this class. So it's OK.
+ updated[row.url] = new_row
+ self._commit_sort_updates_and_sort(updated, column, order)
+ @run_in_main_thread
+ def _commit_sort_updates_and_sort(self, updated, column, order):
+ for i, row in enumerate(self._rows):
+ if row.url in updated:
+ new_row = updated[row.url]
self._rows[i] = new_row
- self._files[row.url] = new_row
- run_in_main_thread(super().sort)(column, order)
+ self._files[new_row.url] = new_row
+ super().sort(column, order)
def _sort_value_is_loaded(self, row, column, ascending):
try:
self.get_sort_value(row, column, ascending)
@@ -271,13 +284,14 @@ def update(self):
@transaction(priority=5)
def reload(self):
self._fs.clear_cache(self._location)
+ files_snapshot = self._snapshot_files()
files = []
try:
file_names = iter(self._fs.iterdir(self._location))
except FileNotFoundError:
self.location_disappeared.emit(self._location)
return
- while not self._shutdown:
+ while not self._shutdown.is_set():
try:
file_name = next(file_names)
except FileNotFoundError:
@@ -287,9 +301,10 @@ def reload(self):
break
else:
url = join(self._location, file_name)
+ self._fs.clear_cache(url)
try:
try:
- file_before = self._files[url]
+ file_before = files_snapshot[url]
except KeyError:
file_ = self._init_file(url)
else:
@@ -301,16 +316,18 @@ def reload(self):
continue
files.append(file_)
else:
- assert self._shutdown
return
self._on_files_reloaded(files)
# We may have found new files that now still need to be loaded:
self._load_remaining_files()
@run_in_main_thread
def _on_files_reloaded(self, rows):
- self._files = {
- row.url: row for row in rows
- }
+ if self._shutdown.is_set():
+ return
+ with self._files_lock:
+ self._files = {
+ row.url: row for row in rows
+ }
self.update()
def get_columns(self):
return self._columns
@@ -326,6 +343,10 @@ def find(self, url):
except KeyError:
raise ValueError('%r is not in list' % url) from None
return self.index(rownum, 0)
+ @run_in_main_thread
+ def _snapshot_files(self):
+ with self._files_lock:
+ return dict(self._files)
def get_rows(self):
return self._files.values()
def get_sort_value(self, row, column, ascending):
@@ -341,16 +362,19 @@ def setData(self, index, value, role):
return super().setData(index, value, role)
@transaction(priority=6, synchronous=True)
def notify_file_added(self, url):
- assert dirname(url) == self._location
+ if dirname(url) != self._location:
+ raise ValueError('url %r not in %r' % (url, self._location))
self._load_files([url])
@transaction(priority=6, synchronous=True)
def notify_file_changed(self, url):
- assert dirname(url) == self._location
+ if dirname(url) != self._location:
+ raise ValueError('url %r not in %r' % (url, self._location))
self._fs.clear_cache(url)
self._load_files([url])
@transaction(priority=6, synchronous=True)
def notify_file_renamed(self, old_url, new_url):
- assert dirname(old_url) == dirname(new_url) == self._location
+ if not (dirname(old_url) == dirname(new_url) == self._location):
+ raise ValueError('urls not in %r' % self._location)
self._fs.clear_cache(old_url)
try:
new_file = self._load_file(new_url)
@@ -369,7 +393,8 @@ def _on_file_renamed(self, old_url, new_file):
self._record_files([new_file], [old_url])
@transaction(priority=6, synchronous=True)
def notify_file_removed(self, url):
- assert dirname(url) == self._location
+ if dirname(url) != self._location:
+ raise ValueError('url %r not in %r' % (url, self._location))
self._fs.clear_cache(url)
self._record_files([], [url])
@transaction(priority=7)
@@ -378,8 +403,8 @@ def _load_remaining_files(self, batch_timeout=.2):
files = []
disappeared = []
all_loaded = False
- for row in self._rows:
- if self._shutdown:
+ for row in list(self._rows):
+ if self._shutdown.is_set():
return
if time() > end_time:
break
@@ -395,7 +420,7 @@ def _load_remaining_files(self, batch_timeout=.2):
if not all_loaded:
self._load_remaining_files()
def shutdown(self):
- self._shutdown = True
+ self._shutdown.set()
# Similarly to why we don't want to call FileWatcher#start() from the
# main thread, we also don't want to call #shutdown() from it to avoid
# potential deadlocks. So do it asynchronously:
diff --git a/src/main/python/fman/impl/model/table.py b/src/main/python/fman/impl/model/table.py
index 33fe1c80..c02ce4bb 100644
--- a/src/main/python/fman/impl/model/table.py
+++ b/src/main/python/fman/impl/model/table.py
@@ -87,9 +87,10 @@ def insert_rows(self, rows, first_rownum=-1):
self.endInsertRows()
def move_rows(self, cut_start, cut_end, insert_start):
dst_row = _get_move_destination(cut_start, cut_end, insert_start)
- assert self.beginMoveRows(
+ if not self.beginMoveRows(
QModelIndex(), cut_start, cut_end - 1, QModelIndex(), dst_row
- )
+ ):
+ raise RuntimeError('beginMoveRows failed')
self._rows.move(cut_start, cut_end, insert_start)
self.endMoveRows()
def update_rows(self, rows, first_rownum):
@@ -129,9 +130,11 @@ def __init__(self):
self._keys = {}
self._lock = RLock()
def __len__(self):
- return len(self._rows)
+ with self._lock:
+ return len(self._rows)
def __getitem__(self, item):
- return self._rows[item]
+ with self._lock:
+ return self._rows[item]
def __setitem__(self, i, row):
with self._lock:
old_row = self._rows[i]
@@ -140,7 +143,8 @@ def __setitem__(self, i, row):
self._keys[row.key] = i
self._check_integrity()
def __iter__(self):
- return iter(self._rows)
+ with self._lock:
+ return iter(list(self._rows))
def reset_to(self, new_rows):
new_keys = {row.key: i for i, row in enumerate(new_rows)}
with self._lock:
@@ -151,7 +155,7 @@ def insert(self, rows, first_rownum):
new_keys = {row.key: first_rownum + i for i, row in enumerate(rows)}
with self._lock:
# Perform this check here, once we have the lock:
- if first_rownum < 0 or first_rownum > len(self._rows) + 1:
+ if first_rownum < 0 or first_rownum > len(self._rows):
raise ValueError('Invalid first_rownum: %d' % first_rownum)
num_rows = len(rows)
for row in self._rows[first_rownum:]:
@@ -183,7 +187,8 @@ def remove(self, start, end):
del self._rows[start:end]
self._check_integrity()
def find(self, key):
- return self._keys[key]
+ with self._lock:
+ return self._keys[key]
def _cut(self, cut_start, cut_end):
with self._lock:
num_rows = len(self._rows)
@@ -200,8 +205,8 @@ def _cut(self, cut_start, cut_end):
self._rows = self._rows[:cut_start] + self._rows[cut_end:]
return result
def _check_integrity(self):
- assert len(self._rows) == len(self._keys), \
- 'Integrity error, likely caused by duplicate rows'
+ if len(self._rows) != len(self._keys):
+ raise RuntimeError('Integrity error, likely caused by duplicate rows')
class Row:
def __init__(self, key, icon, drop_enabled, cells):
diff --git a/src/main/python/fman/impl/model/worker.py b/src/main/python/fman/impl/model/worker.py
index 51066233..2230ef58 100644
--- a/src/main/python/fman/impl/model/worker.py
+++ b/src/main/python/fman/impl/model/worker.py
@@ -21,16 +21,18 @@ def submit(self, priority, fn, *args, **kwargs):
with self._shutdown_lock:
if self._shutdown:
return
- self._queue.put(WorkItem(priority, fn, *args, *kwargs))
+ self._queue.put(WorkItem(priority, fn, *args, **kwargs))
def shutdown(self):
with self._shutdown_lock:
self._shutdown = True
self._queue.put(WorkItem(0, lambda: None))
- self._thread.join()
+ if self._thread.is_alive():
+ self._thread.join()
def _run(self):
while True:
task = self._queue.get()
if task.is_shutdown():
+ self._queue.task_done()
break
task.run()
self._queue.task_done()
@@ -56,7 +58,7 @@ def __lt__(self, other):
return NotImplemented
def __eq__(self, other):
try:
- return self._fn, self._args, self._kwargs, self._priority == \
- other._fn, other._args, other._kwargs, other._priority
+ return (self._fn, self._args, self._kwargs, self._priority) == \
+ (other._fn, other._args, other._kwargs, other._priority)
except AttributeError:
return NotImplemented
\ No newline at end of file
diff --git a/src/main/python/fman/impl/onboarding/__init__.py b/src/main/python/fman/impl/onboarding/__init__.py
index 13aa9575..ea16ee83 100644
--- a/src/main/python/fman/impl/onboarding/__init__.py
+++ b/src/main/python/fman/impl/onboarding/__init__.py
@@ -159,4 +159,6 @@ def _get_body_html(self):
line = re.subn(r'\*([^*]+)\*', highlight(r'\1'), line)[0]
line = re.subn(r'_([^_]+)_', underline(r'\1'), line)[0]
result += line
+ if is_list:
+ result += ''
return result
\ No newline at end of file
diff --git a/src/main/python/fman/impl/onboarding/tutorial.py b/src/main/python/fman/impl/onboarding/tutorial.py
index 73d3b628..9cf5790f 100644
--- a/src/main/python/fman/impl/onboarding/tutorial.py
+++ b/src/main/python/fman/impl/onboarding/tutorial.py
@@ -367,7 +367,8 @@ def _get_step_paragraphs(self, instruction, path):
(now, path, 'Cmd' if is_mac() else 'Ctrl')
)
else:
- assert instruction == 'go to'
+ if instruction != 'go to':
+ raise ValueError('Unexpected instruction: %r' % instruction)
text = "We need to go to *%s*. Please press *%s* to open " \
"fman's GoTo dialog. There, type *%s* followed by *Enter*."\
% (path, self._cmd_p, path)
@@ -506,7 +507,8 @@ def continue_from(src_url, showing_hidden_files=showing_hidden_files):
if dst_is_unc:
unc_parts = dst_drive[2:].split('\\')
server = unc_parts[0]
- assert server == server.upper(), server
+ if server != server.upper():
+ raise ValueError('Server name not uppercase: %r' % server)
src_path = splitscheme(src_url)[1]
if not src_path:
return [('open', server)] + continue_from('network://' + server)
@@ -540,7 +542,8 @@ def _upper_server(unc_path):
r"""
\\server\Folder -> \\SERVER\Folder
"""
- assert unc_path.startswith(r'\\'), unc_path
+ if not unc_path.startswith(r'\\'):
+ raise ValueError('Not a UNC path: %r' % unc_path)
try:
i = unc_path.index('\\', 2)
except ValueError:
diff --git a/src/main/python/fman/impl/plugins/command_registry.py b/src/main/python/fman/impl/plugins/command_registry.py
index e7204763..7bb1a0e6 100644
--- a/src/main/python/fman/impl/plugins/command_registry.py
+++ b/src/main/python/fman/impl/plugins/command_registry.py
@@ -153,9 +153,11 @@ def _set_context(self, pane, file_under_cursor=_DEFAULT):
if file_under_cursor is not self._DEFAULT:
cm = pane._override_file_under_cursor(file_under_cursor)
cm.__enter__()
- yield
- if file_under_cursor is not self._DEFAULT:
- cm.__exit__(None, None, None)
+ try:
+ yield
+ finally:
+ if file_under_cursor is not self._DEFAULT:
+ cm.__exit__(None, None, None)
def _get_default_aliases(cmd_class):
return re.sub(r'([a-z])([A-Z])', r'\1 \2', cmd_class.__name__)\
diff --git a/src/main/python/fman/impl/plugins/config.py b/src/main/python/fman/impl/plugins/config.py
index efbb5b9b..1f30996b 100644
--- a/src/main/python/fman/impl/plugins/config.py
+++ b/src/main/python/fman/impl/plugins/config.py
@@ -3,6 +3,9 @@
from threading import RLock
import json
+import logging
+
+_LOG = logging.getLogger(__name__)
class Config:
def __init__(self, platform):
@@ -54,16 +57,11 @@ def on_quit(self):
for json_name in self._save_on_quit:
try:
self.save_json(json_name)
- except ValueError as error_computing_delta:
- # This can happen for a variety of reasons. One example:
- # When multiple instances of fman are open and another
- # instance has already written to the same json file, then
- # the delta computation may fail with a ValueError. Ignore
- # this so we can at least save the other files in
- # _save_on_quit:
+ except ValueError:
+ _LOG.debug('Delta computation failed for %s', json_name, exc_info=True)
continue
except OSError:
- # Not much we can do. Try the other JSONs at least:
+ _LOG.warning('Failed to save %s on quit', json_name, exc_info=True)
continue
def _reload_cache(self):
old_cache = self._cache
@@ -94,7 +92,7 @@ def load_json(paths):
if result is None:
result = type(next_value)(next_value)
continue
- if type(next_value) != type(result):
+ if not isinstance(next_value, type(result)):
raise ValueError(
'Cannot join types %s and %s.' %
(type(next_value).__name__, type(result).__name__)
@@ -131,7 +129,7 @@ def get_differential_json(obj, paths, final_path):
if old_obj is None:
return obj
else:
- if type(obj) != type(old_obj):
+ if not isinstance(obj, type(old_obj)):
raise ValueError(
'Cannot overwrite value of type %s with different type %s.' %
(type(old_obj).__name__, type(obj).__name__)
diff --git a/src/main/python/fman/impl/plugins/error.py b/src/main/python/fman/impl/plugins/error.py
index 8b19d965..6aa6b51f 100644
--- a/src/main/python/fman/impl/plugins/error.py
+++ b/src/main/python/fman/impl/plugins/error.py
@@ -30,6 +30,7 @@ def _get_plugin_causing_error(self, traceback):
if is_below_dir(frame.filename, plugin_dir):
return plugin_dir
def report(self, message, exc=None):
+ """exc: Exception instance, None to auto-capture, or False to suppress."""
if exc is None:
exc = sys.exc_info()[1]
if exc:
@@ -46,8 +47,8 @@ def handle_system_exit(self, code=0):
self._app.exit(code)
def on_main_window_shown(self, main_window):
self._main_window = main_window
- if self._pending_error_messages:
- self._main_window.show_alert(self._pending_error_messages[0])
+ for message in self._pending_error_messages:
+ self._main_window.show_alert(message)
def _get_plugin_traceback(self, exc):
if isinstance(exc, ThemeError):
return exc.description
diff --git a/src/main/python/fman/impl/quicksearch.py b/src/main/python/fman/impl/quicksearch.py
index 79314844..7a0dc51e 100644
--- a/src/main/python/fman/impl/quicksearch.py
+++ b/src/main/python/fman/impl/quicksearch.py
@@ -113,6 +113,8 @@ def _update_items(self, query):
def _adjust_item_list_ize(self, min_num_items_to_display=7):
num_items = len(self._curr_items)
row_height = self._items.sizeHintForRow(0)
+ if row_height <= 0:
+ return
max_height = num_items * row_height
self._items.setMaximumHeight(max_height)
if num_items >= min_num_items_to_display:
diff --git a/src/main/python/fman/impl/session.py b/src/main/python/fman/impl/session.py
index 58278245..18144e70 100644
--- a/src/main/python/fman/impl/session.py
+++ b/src/main/python/fman/impl/session.py
@@ -68,13 +68,6 @@ def _show_startup_messages(self, main_window):
'Updated to v%s. ' \
'Changelog' % self._fman_version
main_window.show_status_message(status_message, timeout_secs=5)
- def _get_startup_message(self):
- previous_version = self._settings.get('fman_version', None)
- if not previous_version or previous_version == self._fman_version:
- return 'v%s ready.' % self._fman_version
- return 'Updated to v%s. ' \
- 'Changelog' \
- % self._fman_version
def _init_panes(self, panes, pane_infos, paths_on_cmdline):
with ThreadPoolExecutor(max_workers=len(panes)) as executor:
futures = [
diff --git a/src/main/python/fman/impl/usage_helper.py b/src/main/python/fman/impl/usage_helper.py
index a8500d11..1b917963 100644
--- a/src/main/python/fman/impl/usage_helper.py
+++ b/src/main/python/fman/impl/usage_helper.py
@@ -13,7 +13,8 @@ def on_context_menu(self, pane, via, past_events):
def _on_mouse_action(self, pane, events):
if not self._is_first_run:
return False
- assert events, events
+ if not events:
+ return False
if events[-1] == 'AbortedTour' and 'AbortedTour' not in events[:-1]:
response = show_alert(
"Hey, sorry to bother again. You just used the mouse. That "
diff --git a/src/main/python/fman/impl/util/path.py b/src/main/python/fman/impl/util/path.py
index b5ffff8a..3c428aa5 100644
--- a/src/main/python/fman/impl/util/path.py
+++ b/src/main/python/fman/impl/util/path.py
@@ -34,5 +34,11 @@ def normalize(path_):
if path_ == '.':
path_ = ''
# Resolve a/../b
- path_ = re.subn(r'(^|/)([^/]+)/\.\.(?:$|/)', r'\1', path_)[0]
+ while True:
+ path_, count = re.subn(r'(^|/)((?!\.\.(?:/|$))[^/]+)/\.\.(?:$|/)', r'\1', path_)
+ if not count:
+ break
+ # Resolve /.. at root
+ while path_.startswith('/..'):
+ path_ = path_[3:] or '/'
return path_.rstrip('/')
\ No newline at end of file
diff --git a/src/main/python/fman/impl/util/qt/__init__.py b/src/main/python/fman/impl/util/qt/__init__.py
index 9d77ee96..8baddbad 100644
--- a/src/main/python/fman/impl/util/qt/__init__.py
+++ b/src/main/python/fman/impl/util/qt/__init__.py
@@ -16,8 +16,9 @@ def disable_window_animations_mac(window):
# penalties and leads to subtle changes in behaviour. We therefore wait for
# the Show event:
def eventFilter(target, event):
+ from ctypes import c_void_p
from objc import objc_object
- view = objc_object(c_void_p=int(target.winId()))
+ view = objc_object(c_void_p=c_void_p(int(target.winId())))
NSWindowAnimationBehaviorNone = 2
view.window().setAnimationBehavior_(NSWindowAnimationBehaviorNone)
FilterEventOnce(window, QEvent.Show, eventFilter)
diff --git a/src/main/python/fman/impl/util/qt/key_event.py b/src/main/python/fman/impl/util/qt/key_event.py
index fe550873..f9cbc347 100644
--- a/src/main/python/fman/impl/util/qt/key_event.py
+++ b/src/main/python/fman/impl/util/qt/key_event.py
@@ -15,11 +15,11 @@ def matches(self, keys):
modifiers = self.modifiers
if is_mac() and self.key in (Key_Down, Key_Up, Key_Left, Key_Right):
# According to the Qt documentation ([1]), the KeypadModifier flag
- # is set when an arrow key is pressed on OS X because the arrow keys
+ # is set when an arrow key is pressed on macOS because the arrow keys
# are part of the keypad. We don't want our users to have to specify
# this modifier in their keyboard binding files. So we overwrite
# this behaviour of Qt.
- # [1]: http://doc.qt.io/qt-5/qt.html#KeyboardModifier-enum
+ # [1]: https://doc.qt.io/archives/qt-5.15/qt.html#KeyboardModifier-enum
modifiers &= ~KeypadModifier
key, modifiers, keys = self._alias_return_and_enter(modifiers, keys)
return QKeySequence(modifiers | key).matches(QKeySequence(keys)) \
diff --git a/src/main/python/fman/impl/view/cursor_movement.py b/src/main/python/fman/impl/view/cursor_movement.py
index dcca56de..8aea49c2 100644
--- a/src/main/python/fman/impl/view/cursor_movement.py
+++ b/src/main/python/fman/impl/view/cursor_movement.py
@@ -12,10 +12,10 @@ def move_cursor_up(self, toggle_selection=False):
self._move_cursor(self.MoveUp)
def move_cursor_page_up(self, toggle_selection=False):
self._move_cursor(self.MovePageUp, toggle_selection)
- self.move_cursor_up()
+ self.move_cursor_up(toggle_selection)
def move_cursor_page_down(self, toggle_selection=False):
self._move_cursor(self.MovePageDown, toggle_selection)
- self.move_cursor_down()
+ self.move_cursor_down(toggle_selection)
def move_cursor_home(self, toggle_selection=False):
self._move_cursor(self.MoveHome, toggle_selection)
def move_cursor_end(self, toggle_selection=False):
diff --git a/src/main/python/fman/impl/view/single_row_mode.py b/src/main/python/fman/impl/view/single_row_mode.py
index 6e601c31..5569304a 100644
--- a/src/main/python/fman/impl/view/single_row_mode.py
+++ b/src/main/python/fman/impl/view/single_row_mode.py
@@ -30,8 +30,8 @@ def setSelectionModel(self, selectionModel):
self.selectionModel().currentRowChanged.disconnect(
self._current_row_changed
)
- assert self._single_row_delegate
- self.remove_delegate(self._single_row_delegate)
+ if self._single_row_delegate:
+ self.remove_delegate(self._single_row_delegate)
super().setSelectionModel(selectionModel)
selectionModel.currentRowChanged.connect(self._current_row_changed)
self._single_row_delegate = SingleRowModeDelegate(self)
diff --git a/src/main/python/fman/impl/widgets.py b/src/main/python/fman/impl/widgets.py
index c92c08fc..5a591d98 100644
--- a/src/main/python/fman/impl/widgets.py
+++ b/src/main/python/fman/impl/widgets.py
@@ -37,7 +37,7 @@ def exit(self, returnCode=0):
def set_style_sheet(self, stylesheet):
self.setStyleSheet(stylesheet)
def _on_state_changed(self, new_state):
- if new_state == Qt.ApplicationActive:
+ if new_state == Qt.ApplicationActive and self._main_window is not None:
for pane in self._main_window.get_panes():
pane.reload()
@@ -155,7 +155,7 @@ def get_sort_column(self):
return column, ascending
@run_in_main_thread
def get_column_widths(self):
- return [self._file_view.columnWidth(i) for i in (0, 1)]
+ return [self._file_view.columnWidth(i) for i in range(self._model.columnCount())]
@run_in_main_thread
def set_column_widths(self, column_widths):
num_columns = self._model.columnCount()
diff --git a/src/main/resources/base/Plugins/Core/core/__init__.py b/src/main/resources/base/Plugins/Core/core/__init__.py
index 478b81e4..5cdaf17f 100644
--- a/src/main/resources/base/Plugins/Core/core/__init__.py
+++ b/src/main/resources/base/Plugins/Core/core/__init__.py
@@ -30,7 +30,7 @@ def get_sort_value(self, url, is_ascending):
match = re.search(r'\d+', str_)
if match:
minor += str_[:match.start()]
- minor += '%06d' % int(match.group(0))
+ minor += '%020d' % int(match.group(0))
str_ = str_[match.end():]
else:
minor += str_
diff --git a/src/main/resources/base/Plugins/Core/core/commands/__init__.py b/src/main/resources/base/Plugins/Core/core/commands/__init__.py
index 319553d4..b6c88f53 100644
--- a/src/main/resources/base/Plugins/Core/core/commands/__init__.py
+++ b/src/main/resources/base/Plugins/Core/core/commands/__init__.py
@@ -17,7 +17,7 @@
from io import UnsupportedOperation
from itertools import chain
from os import strerror
-from os.path import basename, pardir
+from os.path import pardir
from pathlib import PurePath
from PyQt5.QtCore import QUrl
from PyQt5.QtGui import QDesktopServices
@@ -1730,7 +1730,7 @@ def on_command(self, command_name, args):
except (KeyError, ValueError):
return None
if scheme == 'file://':
- new_scheme = _get_handler_for_archive(basename(path))
+ new_scheme = _get_handler_for_archive(basename(url))
if new_scheme:
try:
if is_dir(url):
diff --git a/src/main/resources/base/Plugins/Core/core/commands/explorer_properties.py b/src/main/resources/base/Plugins/Core/core/commands/explorer_properties.py
index b655352a..854bedda 100644
--- a/src/main/resources/base/Plugins/Core/core/commands/explorer_properties.py
+++ b/src/main/resources/base/Plugins/Core/core/commands/explorer_properties.py
@@ -96,9 +96,11 @@ def _show_file_properties(dir_, filenames):
if not cm:
return
hMenu = win32gui.CreatePopupMenu()
- cm.QueryContextMenu(hMenu, 0, 1, 0x7FFF, CMF_EXPLORE)
- cm.InvokeCommand((0, None, 'properties', '', '', 1, 0, None))
- cm.QueryContextMenu(hMenu, 0, 1, 0x7FFF, CMF_EXPLORE)
+ try:
+ cm.QueryContextMenu(hMenu, 0, 1, 0x7FFF, CMF_EXPLORE)
+ cm.InvokeCommand((0, None, 'properties', '', '', 1, 0, None))
+ finally:
+ win32gui.DestroyMenu(hMenu)
def _show_drive_properties(drive_nobackslash):
sei = SHELLEXECUTEINFO()
diff --git a/src/main/resources/base/Plugins/Core/core/tests/fs/test_zip.py b/src/main/resources/base/Plugins/Core/core/tests/fs/test_zip.py
index 837f2bc2..a68a38d6 100644
--- a/src/main/resources/base/Plugins/Core/core/tests/fs/test_zip.py
+++ b/src/main/resources/base/Plugins/Core/core/tests/fs/test_zip.py
@@ -351,7 +351,7 @@ def _read_directory(self, dir_path):
child_contents = self._read_directory(child)
else:
child_contents = child.read_text()
- result[child.name] = child_contents
+ result[normalize('NFC', child.name)] = child_contents
return result
def _expect_zip_contents(self, contents, zip_file_path):
with TemporaryDirectory() as tmp_dir:
diff --git a/src/main/resources/base/Plugins/Core/core/tests/test_quicksearch_matchers.py b/src/main/resources/base/Plugins/Core/core/tests/test_quicksearch_matchers.py
index f33b67b4..21a87b22 100644
--- a/src/main/resources/base/Plugins/Core/core/tests/test_quicksearch_matchers.py
+++ b/src/main/resources/base/Plugins/Core/core/tests/test_quicksearch_matchers.py
@@ -1,4 +1,5 @@
-from core.quicksearch_matchers import contains_chars_after_separator
+from core.quicksearch_matchers import contains_chars_after_separator, \
+ contains_chars, contains_substring
from unittest import TestCase
class ContainsCharsAfterSeparatorTest(TestCase):
@@ -24,4 +25,34 @@ def test_prefix_match(self):
)
def setUp(self):
super().setUp()
- self.find_chars_after_space = contains_chars_after_separator(' ')
\ No newline at end of file
+ self.find_chars_after_space = contains_chars_after_separator(' ')
+
+class ContainsCharsTest(TestCase):
+ def test_simple_match(self):
+ self.assertEqual([0, 1, 2], contains_chars('abc', 'abc'))
+ def test_sparse_match(self):
+ self.assertEqual([0, 2], contains_chars('abc', 'ac'))
+ def test_no_match(self):
+ self.assertIsNone(contains_chars('abc', 'z'))
+ def test_case_insensitive(self):
+ self.assertEqual([0, 1, 2], contains_chars('ABC', 'abc'))
+ def test_case_insensitive_query_upper(self):
+ self.assertEqual([0, 1, 2], contains_chars('abc', 'ABC'))
+ def test_case_insensitive_mixed(self):
+ self.assertEqual([0, 2], contains_chars('AbC', 'ac'))
+ def test_empty_query(self):
+ self.assertEqual([], contains_chars('abc', ''))
+
+class ContainsSubstringTest(TestCase):
+ def test_simple_match(self):
+ self.assertEqual([1, 2, 3], contains_substring('abcd', 'bcd'))
+ def test_no_match(self):
+ self.assertIsNone(contains_substring('abc', 'xyz'))
+ def test_case_insensitive(self):
+ self.assertEqual([0, 1, 2], contains_substring('ABC', 'abc'))
+ def test_case_insensitive_query_upper(self):
+ self.assertEqual([0, 1, 2], contains_substring('abc', 'ABC'))
+ def test_case_insensitive_mixed(self):
+ self.assertEqual([1, 2], contains_substring('aBc', 'BC'))
+ def test_empty_query(self):
+ self.assertEqual([], contains_substring('abc', ''))
\ No newline at end of file
diff --git a/src/unittest/python/fman_unittest/impl/model/test_model.py b/src/unittest/python/fman_unittest/impl/model/test_model.py
index 00818a9e..1779ee41 100644
--- a/src/unittest/python/fman_unittest/impl/model/test_model.py
+++ b/src/unittest/python/fman_unittest/impl/model/test_model.py
@@ -6,6 +6,7 @@
from fman_unittest.impl.model import StubFileSystem
from PyQt5.QtCore import QObject, pyqtSignal
from random import shuffle, random
+from threading import Thread
from unittest import TestCase
import random
@@ -161,5 +162,39 @@ def f(url, cells, is_loaded=False, is_dir=False):
def c(str_, sort_value_asc=0, sort_value_desc=_NOT_LOADED):
return Cell(str_, sort_value_asc, sort_value_desc)
+class ModelFilesCopyTest(TestCase):
+ def test_files_copy_safe_under_concurrent_mutation(self):
+ """dict.copy() must not raise RuntimeError when _files is mutated."""
+ app = StubApp()
+ executor_before = Executor._INSTANCE
+ Executor._INSTANCE = Executor(app)
+ try:
+ fs = StubFileSystem({})
+ model = Model(fs, 'null://', [Column()])
+ model._files = {('s://%d' % i): None for i in range(1000)}
+ errors = []
+ def copy_loop():
+ try:
+ for _ in range(200):
+ model._files.copy()
+ except RuntimeError as e:
+ errors.append(e)
+ def mutate_loop():
+ for i in range(1000, 1200):
+ model._files['s://%d' % i] = None
+ for i in range(1000, 1200):
+ model._files.pop('s://%d' % i, None)
+ t1 = Thread(target=copy_loop)
+ t2 = Thread(target=mutate_loop)
+ t1.start()
+ t2.start()
+ t1.join()
+ t2.join()
+ self.assertEqual([], errors)
+ finally:
+ app.aboutToQuit.emit()
+ Executor._INSTANCE = executor_before
+
+
class StubApp(QObject):
aboutToQuit = pyqtSignal()
\ No newline at end of file
diff --git a/src/unittest/python/fman_unittest/impl/model/test_worker.py b/src/unittest/python/fman_unittest/impl/model/test_worker.py
new file mode 100644
index 00000000..4eef69d2
--- /dev/null
+++ b/src/unittest/python/fman_unittest/impl/model/test_worker.py
@@ -0,0 +1,89 @@
+from fman.impl.model.worker import Worker, WorkItem
+from threading import Event
+from time import sleep
+from unittest import TestCase
+
+class WorkerTest(TestCase):
+ def test_priority_ordering(self):
+ results = []
+ started = Event()
+ gate = Event()
+ done = Event()
+ def block():
+ started.set()
+ gate.wait()
+ def record(val):
+ results.append(val)
+ if len(results) == 3:
+ done.set()
+ worker = Worker()
+ worker.start()
+ worker.submit(1, block)
+ started.wait()
+ worker.submit(3, record, 'low')
+ worker.submit(1, record, 'high')
+ worker.submit(2, record, 'mid')
+ gate.set()
+ done.wait(timeout=2)
+ worker.shutdown()
+ self.assertEqual(['high', 'mid', 'low'], results)
+ def test_shutdown_completes(self):
+ worker = Worker()
+ worker.start()
+ worker.shutdown()
+ self.assertFalse(worker._thread.is_alive())
+ def test_submit_after_shutdown_ignored(self):
+ results = []
+ worker = Worker()
+ worker.start()
+ worker.shutdown()
+ worker.submit(1, lambda: results.append(1))
+ self.assertEqual([], results)
+ def test_shutdown_before_start(self):
+ worker = Worker()
+ worker.shutdown()
+ def test_exception_does_not_stop_worker(self):
+ results = []
+ done = Event()
+ def record_and_signal():
+ results.append('ok')
+ done.set()
+ worker = Worker()
+ worker.start()
+ worker.submit(1, self._raise_error)
+ worker.submit(2, record_and_signal)
+ done.wait(timeout=2)
+ worker.shutdown()
+ self.assertEqual(['ok'], results)
+ def test_priority_must_be_positive(self):
+ worker = Worker()
+ with self.assertRaises(ValueError):
+ worker.submit(0, lambda: None)
+ def _raise_error(self):
+ raise RuntimeError('test')
+
+class WorkItemTest(TestCase):
+ def test_is_shutdown_zero_priority(self):
+ item = WorkItem(0, lambda: None)
+ self.assertTrue(item.is_shutdown())
+ def test_is_not_shutdown(self):
+ item = WorkItem(1, lambda: None)
+ self.assertFalse(item.is_shutdown())
+ def test_ordering(self):
+ low = WorkItem(1, lambda: None)
+ high = WorkItem(5, lambda: None)
+ self.assertLess(low, high)
+ def test_run_captures_exception(self):
+ captured = []
+ import sys
+ original = sys.excepthook
+ sys.excepthook = lambda *args: captured.append(args[1])
+ try:
+ item = WorkItem(1, self._raise)
+ item.run()
+ finally:
+ sys.excepthook = original
+ self.assertEqual(1, len(captured))
+ self.assertIsInstance(captured[0], ValueError)
+ def _raise(self):
+ raise ValueError('test')
diff --git a/src/unittest/python/fman_unittest/impl/plugins/test_error.py b/src/unittest/python/fman_unittest/impl/plugins/test_error.py
new file mode 100644
index 00000000..945c20b5
--- /dev/null
+++ b/src/unittest/python/fman_unittest/impl/plugins/test_error.py
@@ -0,0 +1,122 @@
+from fman.impl.plugins.error import format_traceback, walk_tb_with_filtering, \
+ TracebackExceptionWithTbFilter, PluginErrorHandler
+from unittest import TestCase
+
+import os
+
+class FormatTracebackTest(TestCase):
+ def test_excludes_frames_from_dir(self):
+ exc = self._make_exception()
+ result = format_traceback(exc, exclude_dirs=[os.path.dirname(__file__)])
+ self.assertNotIn('test_error.py', result)
+ def test_includes_frames_outside_dir(self):
+ exc = self._make_exception()
+ result = format_traceback(exc, exclude_dirs=['/nonexistent'])
+ self.assertIn('_make_exception', result)
+ def test_empty_exclude_dirs(self):
+ exc = self._make_exception()
+ result = format_traceback(exc, exclude_dirs=[])
+ self.assertIn('ValueError', result)
+ def _make_exception(self):
+ try:
+ raise ValueError('test error')
+ except ValueError as e:
+ return e
+
+class WalkTbWithFilteringTest(TestCase):
+ def test_no_filter_yields_all(self):
+ exc = self._make_exception()
+ frames = list(walk_tb_with_filtering(exc.__traceback__))
+ self.assertGreaterEqual(len(frames), 1)
+ def test_filter_excludes_frames(self):
+ exc = self._make_exception()
+ frames = list(walk_tb_with_filtering(exc.__traceback__, lambda tb: False))
+ self.assertEqual([], frames)
+ def test_filter_none_yields_all(self):
+ exc = self._make_exception()
+ all_frames = list(walk_tb_with_filtering(exc.__traceback__))
+ none_frames = list(walk_tb_with_filtering(exc.__traceback__, None))
+ self.assertEqual(len(all_frames), len(none_frames))
+ def _make_exception(self):
+ try:
+ raise RuntimeError('test')
+ except RuntimeError as e:
+ return e
+
+class TracebackExceptionWithTbFilterTest(TestCase):
+ def test_from_exception(self):
+ exc = self._make_chained_exception()
+ te = TracebackExceptionWithTbFilter.from_exception(exc)
+ formatted = ''.join(te.format())
+ self.assertIn('RuntimeError', formatted)
+ def test_with_cause(self):
+ exc = self._make_chained_exception()
+ te = TracebackExceptionWithTbFilter.from_exception(exc)
+ formatted = ''.join(te.format())
+ self.assertIn('ValueError', formatted)
+ self.assertIn('RuntimeError', formatted)
+ def test_with_tb_filter(self):
+ exc = self._make_chained_exception()
+ te = TracebackExceptionWithTbFilter.from_exception(
+ exc, tb_filter=lambda tb: True
+ )
+ formatted = ''.join(te.format())
+ self.assertIn('RuntimeError', formatted)
+ def test_filter_all_frames(self):
+ exc = self._make_chained_exception()
+ te = TracebackExceptionWithTbFilter.from_exception(
+ exc, tb_filter=lambda tb: False
+ )
+ formatted = ''.join(te.format())
+ self.assertIn('RuntimeError', formatted)
+ self.assertNotIn('File', formatted)
+ def test_context_suppressed_when_all_frames_hidden(self):
+ exc = self._make_chained_exception()
+ te = TracebackExceptionWithTbFilter.from_exception(
+ exc, tb_filter=lambda tb: False
+ )
+ self.assertTrue(te.__suppress_context__)
+ def _make_chained_exception(self):
+ try:
+ try:
+ raise ValueError('cause')
+ except ValueError:
+ raise RuntimeError('effect')
+ except RuntimeError as e:
+ return e
+
+class PluginErrorHandlerTest(TestCase):
+ def test_report_stores_pending_without_window(self):
+ handler = self._make_handler()
+ handler.report('test message', exc=False)
+ self.assertEqual(['test message'], handler._pending_error_messages)
+ def test_report_shows_on_window(self):
+ handler = self._make_handler()
+ window = StubMainWindow()
+ handler.on_main_window_shown(window)
+ handler.report('hello', exc=False)
+ self.assertEqual(['hello'], window.alerts)
+ def test_pending_messages_shown_on_window(self):
+ handler = self._make_handler()
+ handler.report('pending', exc=False)
+ window = StubMainWindow()
+ handler.on_main_window_shown(window)
+ self.assertEqual(['pending'], window.alerts)
+ def test_add_remove_dir(self):
+ handler = self._make_handler()
+ handler.add_dir('/plugins/Foo')
+ handler.add_dir('/plugins/Bar')
+ handler.remove_dir('/plugins/Foo')
+ self.assertEqual(['/plugins/Bar'], handler._plugin_dirs)
+ def _make_handler(self):
+ return PluginErrorHandler(StubApp())
+
+class StubApp:
+ def exit(self, code=0):
+ pass
+
+class StubMainWindow:
+ def __init__(self):
+ self.alerts = []
+ def show_alert(self, message):
+ self.alerts.append(message)
diff --git a/src/unittest/python/fman_unittest/impl/plugins/test_plugin.py b/src/unittest/python/fman_unittest/impl/plugins/test_plugin.py
index 8e0df07d..c9921b1c 100644
--- a/src/unittest/python/fman_unittest/impl/plugins/test_plugin.py
+++ b/src/unittest/python/fman_unittest/impl/plugins/test_plugin.py
@@ -1,6 +1,6 @@
from fman.fs import FileSystem
from fman.impl.plugins.plugin import _get_command_name, \
- get_command_class_name, FileSystemWrapper
+ get_command_class_name, FileSystemWrapper, ReportExceptions
from fman_unittest.impl.plugins import StubErrorHandler
from unittest import TestCase
@@ -94,6 +94,35 @@ def setUp(self):
super().setUp()
self._error_handler = StubErrorHandler()
+class ReportExceptionsTest(TestCase):
+ def test_no_exception(self):
+ handler = StubErrorHandler()
+ with ReportExceptions(handler, 'msg') as cm:
+ pass
+ self.assertIsNone(cm.exception)
+ self.assertEqual([], handler.error_messages)
+ def test_reports_exception(self):
+ handler = StubErrorHandler()
+ with ReportExceptions(handler, 'something broke'):
+ raise ValueError('bad')
+ self.assertEqual(['something broke'], handler.error_messages)
+ def test_excludes_exception_class(self):
+ handler = StubErrorHandler()
+ with self.assertRaises(StopIteration):
+ with ReportExceptions(handler, 'msg', exclude={StopIteration}):
+ raise StopIteration()
+ self.assertEqual([], handler.error_messages)
+ def test_exclude_must_be_set(self):
+ handler = StubErrorHandler()
+ with self.assertRaises(ValueError):
+ ReportExceptions(handler, 'msg', exclude=StopIteration)
+ def test_exception_stored(self):
+ handler = StubErrorHandler()
+ with ReportExceptions(handler, 'msg') as cm:
+ raise RuntimeError('stored')
+ self.assertIsInstance(cm.exception, RuntimeError)
+
+
class StubMotherFileSystem:
def __init__(self, columns):
self._columns = columns
diff --git a/src/unittest/python/fman_unittest/impl/util/test_path.py b/src/unittest/python/fman_unittest/impl/util/test_path.py
index eb05ea32..47b645f2 100644
--- a/src/unittest/python/fman_unittest/impl/util/test_path.py
+++ b/src/unittest/python/fman_unittest/impl/util/test_path.py
@@ -40,4 +40,20 @@ def test_trailing_double_dot(self):
def test_single_dot_only(self):
self.assertEqual('', normalize('.'))
def test_pardir_of_subdir(self):
- self.assertEqual('a', normalize('a/b/..'))
\ No newline at end of file
+ self.assertEqual('a', normalize('a/b/..'))
+ def test_multi_level_pardir(self):
+ self.assertEqual('a', normalize('a/b/c/../..'))
+ def test_deep_pardir_chain(self):
+ self.assertEqual('a/d', normalize('a/b/c/../../d'))
+ def test_root_level_pardir(self):
+ self.assertEqual('', normalize('/..'))
+ def test_root_level_double_pardir(self):
+ self.assertEqual('', normalize('/../..'))
+ def test_root_with_path_after_pardir(self):
+ self.assertEqual('/a', normalize('/../a'))
+ def test_empty_path(self):
+ self.assertEqual('', normalize(''))
+ def test_double_slash(self):
+ self.assertEqual('a/b', normalize('a//b'))
+ def test_trailing_slash(self):
+ self.assertEqual('a/b', normalize('a/b/'))
\ No newline at end of file
diff --git a/src/unittest/python/fman_unittest/impl/view/test_resize_cols_to_contents.py b/src/unittest/python/fman_unittest/impl/view/test_resize_cols_to_contents.py
index 4029abf5..91b21df1 100644
--- a/src/unittest/python/fman_unittest/impl/view/test_resize_cols_to_contents.py
+++ b/src/unittest/python/fman_unittest/impl/view/test_resize_cols_to_contents.py
@@ -1,5 +1,5 @@
from fman.impl.view.resize_cols_to_contents import _get_ideal_column_widths, \
- _resize_column
+ _resize_column, _distribute_evenly, _distribute_exponentially
from unittest import TestCase
class GetIdealColumnWidthsTest(TestCase):
@@ -43,6 +43,12 @@ def test_enlarge_trims_last_col(self):
self._expect([2, 1], 0, 2, [1, 2], [1, 1], 3)
def test_rightalign_last_col(self):
self._expect([1, 2, 1], 0, 1, [2, 1, 1], [1, 1, 1])
+ def test_shrink_respects_available_width_cap(self):
+ result = _resize_column(0, 3, [3, 1, 1], [1, 1, 1], 6)
+ self.assertLessEqual(sum(result), 6)
+ def test_shrink_expansion_does_not_exceed_available(self):
+ result = _resize_column(0, 4, [4, 1], [1, 1], 5)
+ self.assertLessEqual(sum(result), 5)
def _expect(
self, result, col, old_size, widths, min_widths, available_width=None
):
@@ -51,4 +57,30 @@ def _expect(
actual = _resize_column(
col, old_size, widths, min_widths, available_width
)
- self.assertEqual(result, actual)
\ No newline at end of file
+ self.assertEqual(result, actual)
+
+class DistributeEvenlyTest(TestCase):
+ def test_exact_division(self):
+ self.assertEqual([5, 5], _distribute_evenly(10, [1, 1]))
+ def test_zero_total(self):
+ self.assertEqual([0, 0], _distribute_evenly(10, [0, 0]))
+ def test_single_proportion(self):
+ self.assertEqual([7], _distribute_evenly(7, [3]))
+ def test_weighted(self):
+ result = _distribute_evenly(100, [3, 1])
+ self.assertEqual(100, sum(result))
+ self.assertGreater(result[0], result[1])
+ def test_zero_width(self):
+ self.assertEqual([0, 0], _distribute_evenly(0, [1, 1]))
+
+class DistributeExponentiallyTest(TestCase):
+ def test_exact_division(self):
+ result = _distribute_exponentially(100, [10, 10])
+ self.assertEqual(100, sum(result))
+ def test_zero_total(self):
+ self.assertEqual([0, 0], _distribute_exponentially(10, [0, 0]))
+ def test_larger_proportion_gets_more(self):
+ result = _distribute_exponentially(100, [10, 1])
+ self.assertGreater(result[0], result[1])
+ def test_zero_width(self):
+ self.assertEqual([0, 0], _distribute_exponentially(0, [5, 5]))
\ No newline at end of file