diff --git a/.gitignore b/.gitignore index 7699786..094b85e 100644 --- a/.gitignore +++ b/.gitignore @@ -4,3 +4,4 @@ .mypy_cache/ __pycache__ .pytest_cache/ +.dir-locals.el \ No newline at end of file diff --git a/mypy.ini b/mypy.ini new file mode 100644 index 0000000..6019f4c --- /dev/null +++ b/mypy.ini @@ -0,0 +1,32 @@ +[mypy] +mypy_path = stubs + +[mypy-sml_sync.screens.components.*] +disallow_any_unimported = True +disallow_untyped_defs = True + +[mypy-sml_sync.screens.components.tests.*] +# Don't typecheck tests yet +ignore_errors = True + +# Dependencies that don't have stubs yet +[mypy-sml.*] +ignore_missing_imports = True + +[mypy-watchdog.*] +ignore_missing_imports = True + +[mypy-semantic-versions.*] +ignore_missing_imports = True + +[mypy-daiquiri] +ignore_missing_imports = True + +[mypy-semantic_version] +ignore_missing_imports = True + +[mypy-pytest] +ignore_missing_imports = True + +[mypy-paramiko] +ignore_missing_imports = True \ No newline at end of file diff --git a/requirements.txt b/requirements.txt index 6aa072e..d34d957 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,7 +1,6 @@ sml daiquiri paramiko -inflect watchdog semantic_version git+https://github.com/jonathanslenders/python-prompt-toolkit@2.0 diff --git a/setup.py b/setup.py index 67dd637..f50d0f6 100644 --- a/setup.py +++ b/setup.py @@ -7,12 +7,19 @@ with open(os.path.join(here, 'sml_sync', 'version.py')) as f: exec(f.read(), {}, version_ns) +packages = [ + 'sml_sync', + 'sml_sync.screens', + 'sml_sync.cli', + 'sml_sync.screens.components' +] + setup( name='sml_sync', version=version_ns['version'], description='SherlockML file synchronizer', author='ASI Data Science', - packages=['sml_sync', 'sml_sync.screens', 'sml_sync.cli'], + packages=packages, entry_points={ 'console_scripts': ['sml-sync=sml_sync:run'] }, @@ -24,7 +31,6 @@ 'sml', 'daiquiri', 'paramiko', - 'inflect', 'watchdog', 'semantic_version', 'prompt_toolkit>=2.0' diff --git a/sml_sync/file_trees.py b/sml_sync/file_trees.py index 4a279f9..c51c2a3 100644 --- a/sml_sync/file_trees.py +++ b/sml_sync/file_trees.py @@ -1,10 +1,9 @@ -import logging import os import stat from datetime import datetime -from .models import DirectoryAttrs, FileAttrs, FsObject, FsObjectType +from .models import FsObjectType, Difference, DifferenceType def get_remote_mtime(path, sftp): @@ -37,18 +36,18 @@ def compare_file_trees(left, right): right_file_paths = {obj.path: obj for obj in right} left_only = [obj for obj in left if obj.path not in right_file_paths] for obj in left_only: - yield ('LEFT_ONLY', obj) + yield Difference(DifferenceType.LEFT_ONLY, left=obj, right=None) right_only = [obj for obj in right if obj.path not in left_file_paths] for obj in right_only: - yield ('RIGHT_ONLY', obj) + yield Difference(DifferenceType.RIGHT_ONLY, left=None, right=obj) for left_obj in left: if left_obj.path in right_file_paths: right_obj = right_file_paths[left_obj.path] if left_obj.obj_type != right_obj.obj_type: - yield ('TYPE_DIFFERENT', left_obj, right_obj) - elif ( - left_obj.attrs != right_obj.attrs and - left_obj.obj_type == FsObjectType.FILE - ): - yield ('ATTRS_DIFFERENT', left_obj, right_obj) + yield Difference( + DifferenceType.TYPE_DIFFERENT, left_obj, right_obj) + elif (left_obj.attrs != right_obj.attrs and + left_obj.obj_type == FsObjectType.FILE): + yield Difference( + DifferenceType.ATTRS_DIFFERENT, left_obj, right_obj) diff --git a/sml_sync/models.py b/sml_sync/models.py index b4a6330..ff44e70 100644 --- a/sml_sync/models.py +++ b/sml_sync/models.py @@ -19,8 +19,14 @@ def without_path_prefix(self, prefix): self.attrs ) + def is_file(self): + return self.obj_type == FsObjectType.FILE -FileAttrs = collections.namedtuple('FileAttrs', ['last_modified']) + def is_directory(self): + return self.obj_type == FsObjectType.DIRECTORY + + +FileAttrs = collections.namedtuple('FileAttrs', ['last_modified', 'size']) DirectoryAttrs = collections.namedtuple('DirectoryAttrs', ['last_modified']) @@ -41,3 +47,21 @@ class ChangeEventType(Enum): 'FsChangeEvent', ['event_type', 'is_directory', 'path', 'extra_args'] ) + + +class DifferenceType(Enum): + # path exists only in left tree + LEFT_ONLY = 'LEFT_ONLY' + + # path exists only in right tree + RIGHT_ONLY = 'RIGHT_ONLY' + + # path exists in both, but is a file in one and a directory in the other + TYPE_DIFFERENT = 'TYPE_DIFFERENT' + + # path exists in both, but they have different attributes + ATTRS_DIFFERENT = 'ATTRS_DIFFERENT' + + +Difference = collections.namedtuple( + 'Difference', ['difference_type', 'left', 'right']) diff --git a/sml_sync/screens/choose_remote_dir.py b/sml_sync/screens/choose_remote_dir.py index a3c1835..2575aa2 100644 --- a/sml_sync/screens/choose_remote_dir.py +++ b/sml_sync/screens/choose_remote_dir.py @@ -7,11 +7,9 @@ from enum import Enum from queue import Empty, Queue -from prompt_toolkit.application.current import get_app +from prompt_toolkit.application import get_app from prompt_toolkit.key_binding import KeyBindings -from prompt_toolkit.layout import HSplit, VSplit -from prompt_toolkit.layout.containers import Window -from prompt_toolkit.layout.controls import FormattedTextControl +from prompt_toolkit.layout import HSplit, VSplit, Window, FormattedTextControl from prompt_toolkit.widgets import TextArea from ..pubsub import Messages diff --git a/sml_sync/screens/components/__init__.py b/sml_sync/screens/components/__init__.py new file mode 100644 index 0000000..39c08d1 --- /dev/null +++ b/sml_sync/screens/components/__init__.py @@ -0,0 +1,3 @@ + +from .table import Table, TableColumn, ColumnSettings, Alignment # noqa +from .vertical_menu import MenuEntry, VerticalMenu # noqa diff --git a/sml_sync/screens/components/table.py b/sml_sync/screens/components/table.py new file mode 100644 index 0000000..4c81fd9 --- /dev/null +++ b/sml_sync/screens/components/table.py @@ -0,0 +1,112 @@ +from collections import namedtuple +import itertools +from enum import Enum +from typing import List, Optional + +from prompt_toolkit.buffer import Buffer +from prompt_toolkit.document import Document +from prompt_toolkit.layout import ( + Window, HSplit, FormattedTextControl, BufferControl, ScrollbarMargin, + Container +) + + +class Alignment(Enum): + RIGHT = 'RIGHT' + LEFT = 'LEFT' + + +class ColumnSettings(object): + + def __init__(self, alignment: Alignment = Alignment.LEFT) -> None: + self.alignment = alignment + + +class TableColumn( + namedtuple('Column', ['rows', 'header', 'settings'])): + + def __new__( + cls, rows: List[str], + header: str, + settings: Optional[ColumnSettings] = None + ) -> 'TableColumn': + if settings is None: + settings = ColumnSettings() + return super(TableColumn, cls).__new__(cls, rows, header, settings) + + +class Table(object): + + def __init__(self, columns: List[TableColumn], sep: str = ' ') -> None: + if len(set(len(column.rows) for column in columns)) not in {0, 1}: + raise ValueError('All columns must have the same number of rows.') + + self._sep = sep + + formatted_headers = [] + formatted_columns = [] + for column in columns: + width = self._get_column_width(column) + formatted_rows = [ + self._format_cell(row, column.settings, width) + for row in column.rows + ] + formatted_headers.append(column.header.ljust(width, ' ')) + formatted_columns.append(formatted_rows) + + self.window = HSplit( + self._header_windows(formatted_headers) + + self._body_windows(formatted_columns) + ) + + def _get_column_width(self, column: TableColumn) -> int: + width = max( + len(column.header), + max((len(row) for row in column.rows), default=0) + ) + return width + + def _format_cell( + self, + content: str, + column_settings: ColumnSettings, + width: int + ) -> str: + if column_settings.alignment == Alignment.LEFT: + return content.ljust(width) + else: + return content.rjust(width) + + def _header_windows(self, formatted_headers: List[str]) -> List[Window]: + if len(formatted_headers): + header_control = FormattedTextControl( + self._sep.join(formatted_headers)) + header_windows = [Window(header_control, height=1)] + else: + header_windows = [Window(height=1, width=0)] + return header_windows + + def _body_windows( + self, + formatted_columns: List[List[str]] + ) -> List[Window]: + rows = list(itertools.zip_longest(*formatted_columns, fillvalue='')) + if rows: + rows_string = [self._sep.join(row) for row in rows] + table_body = '\n'.join(rows_string) + + document = Document(table_body, cursor_position=0) + _buffer = Buffer(document=document, read_only=True) + self._body_control = BufferControl(_buffer) + body_windows = [ + Window( + self._body_control, + right_margins=[ScrollbarMargin(display_arrows=True)] + ) + ] + else: + body_windows = [] + return body_windows + + def __pt_container__(self) -> Container: + return self.window diff --git a/sml_sync/screens/components/tests/__init__.py b/sml_sync/screens/components/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/sml_sync/screens/components/tests/test_table.py b/sml_sync/screens/components/tests/test_table.py new file mode 100644 index 0000000..f394cbf --- /dev/null +++ b/sml_sync/screens/components/tests/test_table.py @@ -0,0 +1,119 @@ + +import textwrap + +import pytest + +from .. import Table, TableColumn, ColumnSettings, Alignment + + +def test_simple_table(): + col1 = TableColumn(rows=['a', 'b', 'c'], header='t1') + col2 = TableColumn(rows=['d', 'e', 'f'], header='t2') + + table = Table([col1, col2]) + + assert len(table.window.children) == 2 + [header_window, body_window] = table.window.children + + assert header_window.content.text == 't1 t2' + assert body_window.content.buffer.text == textwrap.dedent( + """\ + a d + b e + c f """) # noqa: W291 (ignore trailing whitespace) + assert table.preferred_width(100).preferred == 5 + assert table.preferred_height(5, 100).preferred == 4 + + +def test_table_varying_row_lengths(): + col1 = TableColumn(rows=['a', 'some-long-value'], header='t1') + col2 = TableColumn(rows=['long', 'b'], header='t2') + + table = Table([col1, col2]) + + assert len(table.window.children) == 2 + [header_window, body_window] = table.window.children + + assert header_window.content.text == textwrap.dedent("""\ + t1 t2 """) + assert body_window.content.buffer.text == textwrap.dedent( + """\ + a long + some-long-value b """) + assert table.preferred_width(100).preferred == 20 + assert table.preferred_height(5, 100).preferred == 3 + + +def test_different_length_rows(): + col1 = TableColumn(rows=['a', 'b', 'c'], header='t1') + col2 = TableColumn(rows=['e'], header='t2') + + with pytest.raises(ValueError): + Table([col1, col2]) + + +def test_no_rows(): + col1 = TableColumn(rows=[], header='t1') + col2 = TableColumn(rows=[], header='t2') + + table = Table([col1, col2]) + + assert len(table.window.children) == 1 + [header_window] = table.window.children + + assert header_window.content.text == 't1 t2' + assert table.preferred_width(100).preferred == 5 + assert table.preferred_height(5, 100).preferred == 1 + + +def test_no_columns(): + table = Table([]) + + assert len(table.window.children) == 1 + + assert table.preferred_width(100).preferred == 0 + assert table.preferred_height(0, 100).preferred == 1 + + +def test_custom_separator(): + col1 = TableColumn(rows=['a', 'b', 'c'], header='t1') + col2 = TableColumn(rows=['d', 'e', 'f'], header='t2') + + table = Table([col1, col2], sep=' | ') + + assert len(table.window.children) == 2 + [header_window, body_window] = table.window.children + + assert header_window.content.text == 't1 | t2' + assert body_window.content.buffer.text == textwrap.dedent( + """\ + a | d + b | e + c | f """) # noqa: W291 (ignore trailing whitespace) + assert table.preferred_width(100).preferred == 7 + assert table.preferred_height(5, 100).preferred == 4 + + +def test_right_align(): + col1 = TableColumn(rows=['a', 'b', 'c'], header='header1') + col2 = TableColumn( + rows=['d', 'e', 'f'], + header='header2', + settings=ColumnSettings(alignment=Alignment.RIGHT) + ) + + table = Table([col1, col2], sep='|') + + assert len(table.window.children) == 2 + [header_window, body_window] = table.window.children + + retrieved_table = ( + header_window.content.text + '\n' + + body_window.content.buffer.text + ) + assert retrieved_table == textwrap.dedent( + """\ + header1|header2 + a | d + b | e + c | f""") # noqa: W291 (ignore trailing whitespace) diff --git a/sml_sync/screens/components/tests/test_vertical_menu.py b/sml_sync/screens/components/tests/test_vertical_menu.py new file mode 100644 index 0000000..c4865c2 --- /dev/null +++ b/sml_sync/screens/components/tests/test_vertical_menu.py @@ -0,0 +1,162 @@ +from unittest.mock import Mock + +from prompt_toolkit.layout import to_container +from prompt_toolkit.key_binding.key_processor import KeyProcessor, KeyPress + +from .. import VerticalMenu, MenuEntry + + +def get_menu_text(menu): + """ Get the formatted text corresponding to the menu """ + control = to_container(menu).content + return control.text + + +def simulate_key(menu, key): + """ Simulate passing `key` to a menu """ + control = to_container(menu).content + key_bindings = control.key_bindings + key_processor = KeyProcessor(key_bindings) + key_processor.feed(KeyPress(key)) + key_processor.process_keys() + + +def test_simple_menu(): + entry1 = MenuEntry(1, 'menu entry 1') + entry2 = MenuEntry(2, 'menu entry 2') + + menu = VerticalMenu([entry1, entry2]) + + menu_text = get_menu_text(menu) + assert len(menu_text) == 2 + [menu_line1, menu_line2] = menu_text + assert menu_line1 == ('reverse', 'menu entry 1\n') + assert menu_line2 == ('', 'menu entry 2\n') + + +def test_key_down(): + entry1 = MenuEntry(1, 'menu entry 1') + entry2 = MenuEntry(2, 'menu entry 2') + + menu = VerticalMenu([entry1, entry2]) + + simulate_key(menu, 'down') + + menu_text = get_menu_text(menu) + assert len(menu_text) == 2 + [menu_line1, menu_line2] = menu_text + assert menu_line1 == ('', 'menu entry 1\n') + assert menu_line2 == ('reverse', 'menu entry 2\n') + + assert menu.current_selection == 2 + + +def test_key_up(): + entry1 = MenuEntry(1, 'menu entry 1') + entry2 = MenuEntry(2, 'menu entry 2') + + menu = VerticalMenu([entry1, entry2]) + + simulate_key(menu, 'down') + simulate_key(menu, 'up') + + menu_text = get_menu_text(menu) + assert len(menu_text) == 2 + [menu_line1, menu_line2] = menu_text + assert menu_line1 == ('reverse', 'menu entry 1\n') + assert menu_line2 == ('', 'menu entry 2\n') + + assert menu.current_selection == 1 + + +def test_wrap_keys(): + entry1 = MenuEntry(1, 'menu entry 1') + entry2 = MenuEntry(2, 'menu entry 2') + + menu = VerticalMenu([entry1, entry2]) + + simulate_key(menu, 'down') + simulate_key(menu, 'down') + + menu_text = get_menu_text(menu) + assert len(menu_text) == 2 + [menu_line1, menu_line2] = menu_text + assert menu_line1 == ('reverse', 'menu entry 1\n') + assert menu_line2 == ('', 'menu entry 2\n') + + assert menu.current_selection == 1 + + +def test_callback_called(): + mock_callback = Mock() + + entry1 = MenuEntry(1, 'menu entry 1') + entry2 = MenuEntry(2, 'menu entry 2') + + menu = VerticalMenu([entry1, entry2]) + + menu.register_menu_change_callback(mock_callback) + + simulate_key(menu, 'down') + + mock_callback.assert_called_with(2) + + +def test_zero_entries(): + menu = VerticalMenu([]) + menu_text = get_menu_text(menu) + assert len(menu_text) == 0 + assert menu.current_selection is None + simulate_key(menu, 'up') + assert menu.current_selection is None + + +def test_single_entry(): + menu = VerticalMenu([MenuEntry('only', 'menu entry')]) + menu_text = get_menu_text(menu) + assert len(menu_text) == 1 + [menu_line1] = menu_text + assert menu_line1 == ('reverse', 'menu entry\n') + assert menu.current_selection == 'only' + simulate_key(menu, 'up') + + menu_text = get_menu_text(menu) + assert len(menu_text) == 1 + [menu_line1] = menu_text + assert menu_line1 == ('reverse', 'menu entry\n') + assert menu.current_selection == 'only' + + +def test_different_entry_widths(): + entry1 = MenuEntry(1, 'short') + entry2 = MenuEntry(2, 'very long entry') + + menu = VerticalMenu([entry1, entry2]) + menu_text = get_menu_text(menu) + + assert len(menu_text) == 2 + [menu_line1, menu_line2] = menu_text + assert menu_line1 == ('reverse', 'short\n') + assert menu_line2 == ('', 'very long entry\n') + + width = to_container(menu).preferred_width(100) + assert width.preferred == 15 + assert width.min == 0 + assert width.max > 100 + + +def test_fixed_width(): + entry1 = MenuEntry(1, 'short') + entry2 = MenuEntry(2, 'very long entry') + + menu = VerticalMenu([entry1, entry2], width=8) + menu_text = get_menu_text(menu) + + assert len(menu_text) == 2 + [menu_line1, menu_line2] = menu_text + assert menu_line1 == ('reverse', 'short \n') + assert menu_line2 == ('', 'very lon\n') + + width = to_container(menu).preferred_width(100) + assert width.preferred == 8 + assert width.min == width.max == 8 diff --git a/sml_sync/screens/components/vertical_menu.py b/sml_sync/screens/components/vertical_menu.py new file mode 100644 index 0000000..b42e735 --- /dev/null +++ b/sml_sync/screens/components/vertical_menu.py @@ -0,0 +1,93 @@ +from collections import namedtuple + +from typing import List, Optional, Any, Callable + +from prompt_toolkit.key_binding import KeyBindings +from prompt_toolkit.layout import Window, FormattedTextControl, Container + +MenuEntry = namedtuple('MenuEntry', ['id_', 'text']) + + +class VerticalMenu(object): + + def __init__( + self, + entries: List[MenuEntry], + width: Optional[int] = None + ) -> None: + self._current_index = 0 + self._entries = entries + if width is None: + self._formatted_entries = [entry.text for entry in self._entries] + else: + self._formatted_entries = [ + _ensure_width(entry.text, width) for entry in self._entries + ] + self._control = FormattedTextControl( + '', focusable=True, show_cursor=False, + key_bindings=self._get_key_bindings()) + self._set_control_text() + self._window = Window(self._control, width=width) + self._menu_change_callbacks: List[Callable[[Any], None]] = [] + + @property + def current_selection(self) -> Any: + if self._entries: + return self._entries[self._current_index].id_ + else: + # No items in the menu + return None + + def register_menu_change_callback( + self, + callback: Callable[[Any], None] + ) -> None: + self._menu_change_callbacks.append(callback) + + def _execute_callbacks(self, new_selection: Any) -> None: + for callback in self._menu_change_callbacks: + callback(new_selection) + + def _select_next(self) -> None: + self._set_selection_index(self._current_index + 1) + + def _select_previous(self) -> None: + self._set_selection_index(self._current_index - 1) + + def _get_key_bindings(self) -> KeyBindings: + bindings = KeyBindings() + + @bindings.add('up') + def up_key(event: Any) -> None: + self._select_previous() + + @bindings.add('down') + def down_key(event: Any) -> None: + self._select_next() + + return bindings + + def _set_selection_index(self, new_index: int) -> None: + if self._entries: + new_index = new_index % len(self._entries) + if self._current_index != new_index: + self._current_index = new_index + self._set_control_text() + self._execute_callbacks(self.current_selection) + + def _set_control_text(self) -> None: + control_lines = [] + for ientry, entry in enumerate(self._formatted_entries): + style = 'reverse' if ientry == self._current_index else '' + control_lines.append((style, entry + '\n')) + self._control.text = control_lines + + def __pt_container__(self) -> Container: + return self._window + + +def _ensure_width(inp: str, width: int) -> str: + """ + Ensure that string `inp` is exactly `width` characters long. + """ + return inp[:width].ljust(width) diff --git a/sml_sync/screens/diff.py b/sml_sync/screens/diff.py index 6b6d84a..846bc33 100644 --- a/sml_sync/screens/diff.py +++ b/sml_sync/screens/diff.py @@ -1,195 +1,302 @@ +import logging from enum import Enum -import inflect +from prompt_toolkit.application import get_app from prompt_toolkit.key_binding import KeyBindings -from prompt_toolkit.layout import HSplit, VSplit -from prompt_toolkit.layout.containers import FloatContainer, Window -from prompt_toolkit.layout.controls import FormattedTextControl +from prompt_toolkit.layout import ( + HSplit, VSplit, to_container, FloatContainer, Window, + FormattedTextControl +) +from prompt_toolkit.widgets import TextArea, VerticalLine from ..pubsub import Messages +from ..models import DifferenceType from .base import BaseScreen from .help import help_modal +from .components import ( + Table, TableColumn, VerticalMenu, MenuEntry, ColumnSettings, Alignment) +from .humanize import naturaltime, naturalsize HELP_TITLE = 'Differences between local directory and SherlockML' HELP_TEXT = """\ -This is a summary of the differences between SherlockML and -your local directory. It summarizes the files that exist -only on SherlockML, the files that exist on locally and the -files that are on both, but with different modification times. +Synchronize your local filesystem and the SherlockML filesystem. + +Three synchronization modes are supported: + +'Up' will push all local changes to SherlockML. This will +erase any file that is on SherlockML, but not available locally. + +'Down' will bring all the changes down from SherlockML. This +will erase any file that is present locally but not on SherlockML. + +'Watch' enters `watch` mode. Any time you save, move or delete +a file or a directory, the change is automatically replicated +on SherlockML. Keys: - [u] Push all the local changes to SherlockML. This will - erase any file that is on SherlockML, but not available - locally. - [d] Bring all the changes down from SherlockML. This will - erase any file that is present locally but not on - SherlockML. [r] Refresh differences between the local file system and SherlockML. - [w] Enter incremental synchronization mode, where changes - to the local file system are automatically replicated - on SherlockML. [q] Quit the application. [?] Toggle this message. """ -class SummaryContainerName(Enum): - LOCAL = 'LOCAL' - REMOTE = 'REMOTE' - BOTH = 'BOTH' +UP_SYNC_HELP_TEXT = """\ +Press [u] to modify the SherlockML workspace so that it mirrors your local disk. +This will make the following changes to your SherlockML workspace: +""" -class Summary(object): +DOWN_SYNC_HELP_TEXT = """\ +Press [d] to modify your local filesystem so that it mirrors the SherlockML workspace. - def __init__(self, differences): - self._differences = differences - self._has_differences = False - self._current_index = None - self._menu_containers = [] - self._menu_container_names = [] - self._margin_control = FormattedTextControl('') - self._margin = Window(self._margin_control, width=4) +This will make the following changes to your local disk: +""" - self._inflect = inflect.engine() - self._plural = self._inflect.plural - self._plural_verb = self._inflect.plural_verb +WATCH_HELP_TEXT = """\ +Press [w] to enter `watch` mode. Any time you save, move or delete a file, the change is automatically replicated on SherlockML. +""" - self._render_containers(differences) +FULLY_SYNCHRONIZED_HELP_TEXT = """\ +Your local disk and the SherlockML workspace are fully synchronized. +""" - @property - def current_focus(self): - if self._has_differences: - return self._menu_container_names[self._current_index] - else: - return None + +class SelectionName(Enum): + UP = 'UP' + DOWN = 'DOWN' + WATCH = 'WATCH' + + +class DiffScreenMessages(Enum): + """ + Messages used internally in the differences screen + """ + SELECTION_UPDATED = 'SELECTION_UPDATED' + + +ACTION_TEXT = { + (DifferenceType.LEFT_ONLY, SelectionName.UP): 'create remote', + (DifferenceType.RIGHT_ONLY, SelectionName.DOWN): 'create local', + (DifferenceType.LEFT_ONLY, SelectionName.DOWN): 'delete local', + (DifferenceType.RIGHT_ONLY, SelectionName.UP): 'delete remote', + (DifferenceType.TYPE_DIFFERENT, SelectionName.UP): 'replace remote', + (DifferenceType.TYPE_DIFFERENT, SelectionName.DOWN): 'replace local', + (DifferenceType.ATTRS_DIFFERENT, SelectionName.UP): 'replace remote', + (DifferenceType.ATTRS_DIFFERENT, SelectionName.DOWN): 'replace local' +} + + +class Summary(object): + + def __init__(self, exchange): + self._exchange = exchange + self._current_index = 0 + self._menu_container = VerticalMenu([ + MenuEntry(SelectionName.UP, 'Up'), + MenuEntry(SelectionName.DOWN, 'Down'), + MenuEntry(SelectionName.WATCH, 'Watch'), + ], width=10) + self._menu_container.register_menu_change_callback( + lambda new_selection: self._on_new_selection(new_selection) + ) + self.container = VSplit([ + Window(width=1), + HSplit([ + Window(height=1), + self._menu_container, + Window() + ]) + ]) @property - def containers(self): - return [VSplit([self._margin, HSplit(self._menu_containers)])] - - def focus_next(self): - if self._has_differences: - return self._set_selection_index(self._current_index + 1) - - def focus_previous(self): - if self._has_differences: - return self._set_selection_index(self._current_index - 1) - - def _set_selection_index(self, new_index): - if self._has_differences: - # Wrap around when selecting - self._current_index = new_index % len(self._menu_containers) - margin_lines = [] - for icontainer in range(len(self._menu_containers)): - margin_lines.append( - ' > ' if icontainer == self._current_index - else (' ' * 4) - ) - margin_text = '\n'.join(margin_lines) - self._margin_control.text = margin_text - return self._menu_container_names[self._current_index] - - def _render_containers(self, differences): - extra_local_paths = [ - difference[1].path for difference in differences - if difference[0] == 'LEFT_ONLY' - ] - extra_remote_paths = [ - difference[1].path for difference in differences - if difference[0] == 'RIGHT_ONLY' - ] - other_differences = [ - difference[1].path for difference in differences - if difference[0] in {'TYPE_DIFFERENT', 'ATTRS_DIFFERENT'} - ] - if not extra_local_paths and not extra_remote_paths and not other_differences: - self._has_differences = False - self._menu_containers = [ - Window( - FormattedTextControl( - 'Local directory and SherlockML are synchronized.'), - height=1) - ] - else: - self._has_differences = True - if extra_local_paths: - text = 'There {} {} {} that {} locally but not on SherlockML'.format( - self._plural_verb('is', len(extra_local_paths)), - len(extra_local_paths), - self._plural('file', len(extra_local_paths)), - self._plural_verb('exists', len(extra_local_paths)) - ) - container = Window(FormattedTextControl(text), height=1) - self._menu_containers.append(container) - self._menu_container_names.append(SummaryContainerName.LOCAL) - if extra_remote_paths: - text = 'There {} {} {} that {} only on SherlockML'.format( - self._plural_verb('is', len(extra_remote_paths)), - len(extra_remote_paths), - self._plural('file', len(extra_remote_paths)), - self._plural_verb('exists', len(extra_remote_paths)) - ) - container = Window(FormattedTextControl(text), height=1) - self._menu_containers.append(container) - self._menu_container_names.append(SummaryContainerName.REMOTE) - if other_differences: - text = 'There {} {} {} that {} not synchronized'.format( - self._plural_verb('is', len(other_differences)), - len(other_differences), - self._plural('file', len(other_differences)), - self._plural_verb('is', len(other_differences)) - ) - container = Window(FormattedTextControl(text), height=1) - self._menu_containers.append(container) - self._menu_container_names.append(SummaryContainerName.BOTH) - self._set_selection_index(0) + def current_selection(self): + return self._menu_container.current_selection + + def gain_focus(self, app): + app.layout.focus(self._menu_container) + + def _on_new_selection(self, new_selection): + self._exchange.publish( + DiffScreenMessages.SELECTION_UPDATED, new_selection) class Details(object): - def __init__(self, differences, initial_focus): - self._focus = initial_focus + def __init__(self, exchange, differences, initial_selection): + self._selection = initial_selection self._differences = differences - self._control = FormattedTextControl('') - self.container = Window(self._control) + self._exchange = exchange + self._table = None + self.container = HSplit([]) + self._render() - def set_focus(self, new_focus): - self._focus = new_focus + self._subscription_id = self._exchange.subscribe( + DiffScreenMessages.SELECTION_UPDATED, + self._set_selection + ) + + def gain_focus(self, app): + app.layout.focus(self._table) + + def stop(self): + try: + self._exchange.unsubscribe(self._subscription_id) + except AttributeError: + logging.warning( + 'Tried to unsubscribe from exchange before ' + 'subscription was activated.' + ) + + def _set_selection(self, new_selection): + self._selection = new_selection self._render() def _render(self): - if self._focus is None: - self._container = Window() - elif self._focus == SummaryContainerName.LOCAL: - paths = [ - difference[1].path - for difference in self._differences - if difference[0] == 'LEFT_ONLY' - ] - self._render_paths(paths) - elif self._focus == SummaryContainerName.REMOTE: - paths = [ - difference[1].path - for difference in self._differences - if difference[0] == 'RIGHT_ONLY' - ] - self._render_paths(paths) + if self._selection in {SelectionName.UP, SelectionName.DOWN}: + self._render_differences(self._differences, self._selection) else: - paths = [ - difference[1].path - for difference in self._differences - if difference[0] in {'TYPE_DIFFERENT', 'ATTRS_DIFFERENT'} - ] - self._render_paths(paths) + self._render_watch() + get_app().invalidate() + + def _render_local_mtime(self, difference): + if difference.left is not None and difference.left.is_file(): + return naturaltime(difference.left.attrs.last_modified) + return '-' + + def _render_remote_mtime(self, difference): + if difference.right is not None and difference.right.is_file(): + return naturaltime(difference.right.attrs.last_modified) + return '-' + + def _render_local_size(self, difference): + if difference.left is not None and difference.left.is_file(): + return naturalsize(difference.left.attrs.size) + return '-' + + def _render_remote_size(self, difference): + if difference.right is not None and difference.right.is_file(): + return naturalsize(difference.right.attrs.size) + return '-' + + def _render_help_box(self, text): + text_area = TextArea( + text, focusable=False, read_only=True, dont_extend_height=True) + return to_container(text_area) + + def _render_watch(self): + help_box = self._render_help_box(WATCH_HELP_TEXT) + self.container.children = [Window(height=1), help_box, Window()] + + def _size_transferred(self, difference, direction): + if ( + difference.difference_type == DifferenceType.LEFT_ONLY + and difference.left.is_file() + ): + return difference.left.attrs.size + elif ( + difference.difference_type == DifferenceType.RIGHT_ONLY + and difference.right.is_file() + ): + return difference.right.attrs.size + elif ( + difference.difference_type in + {DifferenceType.TYPE_DIFFERENT, DifferenceType.ATTRS_DIFFERENT} + ): + if ( + direction == SelectionName.UP + and difference.left.is_file() + ): + return difference.left.attrs.size + elif ( + direction == SelectionName.DOWN + and difference.right.is_file() + ): + return difference.right.attrs.size + return 0 + return 0 + + def _render_table(self, differences, direction): + def sort_key(difference): + """ Order first by action, then by size """ + text = ACTION_TEXT[(difference.difference_type, direction)] + size = self._size_transferred(difference, direction) + if 'delete' in text: + return (0, -size) + elif 'replace' in text: + return (1, -size) + else: + return (2, -size) + + sorted_differences = sorted(differences, key=sort_key) + + paths = [] + actions = [] + local_mtimes = [] + remote_mtimes = [] + local_sizes = [] + remote_sizes = [] + + for difference in sorted_differences: + if difference.difference_type == DifferenceType.LEFT_ONLY: + paths.append(difference.left.path) + elif difference.difference_type == DifferenceType.RIGHT_ONLY: + paths.append(difference.right.path) + else: + paths.append(difference.left.path) + + local_mtimes.append(self._render_local_mtime(difference)) + remote_mtimes.append(self._render_remote_mtime(difference)) + + local_sizes.append(self._render_local_size(difference)) + remote_sizes.append(self._render_remote_size(difference)) + + action = ACTION_TEXT[(difference.difference_type, direction)] + actions.append(action) + + columns = [ + TableColumn(paths, 'PATH'), + TableColumn(actions, 'ACTION'), + TableColumn( + local_mtimes, + 'LOCAL MTIME', + ColumnSettings(alignment=Alignment.RIGHT)), + TableColumn( + remote_mtimes, + 'REMOTE MTIME', + ColumnSettings(alignment=Alignment.RIGHT)), + TableColumn( + local_sizes, + 'LOCAL SIZE', + ColumnSettings(alignment=Alignment.RIGHT)), + TableColumn( + remote_sizes, + 'REMOTE SIZE', + ColumnSettings(alignment=Alignment.RIGHT)) + ] + table = Table(columns, sep=' ') + return table - def _render_paths(self, paths): - path_texts = [' {}'.format(path) for path in paths] - self._control.text = '\n'.join(path_texts) + def _render_differences(self, differences, direction): + if not differences: + help_box = self._render_help_box(FULLY_SYNCHRONIZED_HELP_TEXT) + self.container.children = [Window(height=1), help_box, Window()] + else: + self._table = self._render_table(differences, direction) + help_box = self._render_help_box( + UP_SYNC_HELP_TEXT + if direction == SelectionName.UP else + DOWN_SYNC_HELP_TEXT + ) + self.container.children = [ + Window(height=1), + help_box, + to_container(self._table), + ] class DifferencesScreen(BaseScreen): @@ -198,63 +305,68 @@ def __init__(self, differences, exchange): super().__init__() self._exchange = exchange self._bottom_toolbar = Window(FormattedTextControl( - '[d] Sync SherlockML files down ' - '[u] Sync local files up ' '[r] Refresh ' - '[w] Incremental sync from local changes\n' '[?] Help ' '[q] Quit' - ), height=2, style='reverse') - self._summary = Summary(differences) - self._details = Details(differences, self._summary.current_focus) + ), height=1, style='reverse') + self._summary = Summary(exchange) + self._details = Details( + exchange, differences, self._summary.current_selection) self.bindings = KeyBindings() - @self.bindings.add('d') + @self.bindings.add('d') # noqa: F811 def _(event): - self._exchange.publish(Messages.SYNC_SHERLOCKML_TO_LOCAL) + if self._summary.current_selection == SelectionName.DOWN: + self._exchange.publish(Messages.SYNC_SHERLOCKML_TO_LOCAL) - @self.bindings.add('u') + @self.bindings.add('u') # noqa: F811 def _(event): - self._exchange.publish(Messages.SYNC_LOCAL_TO_SHERLOCKML) + if self._summary.current_selection == SelectionName.UP: + self._exchange.publish(Messages.SYNC_LOCAL_TO_SHERLOCKML) - @self.bindings.add('r') + @self.bindings.add('r') # noqa: F811 def _(event): self._exchange.publish(Messages.REFRESH_DIFFERENCES) - @self.bindings.add('w') + @self.bindings.add('w') # noqa: F811 def _(event): - self._exchange.publish(Messages.START_WATCH_SYNC) + if self._summary.current_selection == SelectionName.WATCH: + self._exchange.publish(Messages.START_WATCH_SYNC) - @self.bindings.add('?') + @self.bindings.add('?') # noqa: F811 def _(event): self._toggle_help() - @self.bindings.add('tab') - @self.bindings.add('down') - @self.bindings.add('left') + @self.bindings.add('right') # noqa: F811 def _(event): - new_focus = self._summary.focus_next() - self._details.set_focus(new_focus) + if self._summary.current_selection != SelectionName.WATCH: + self._details.gain_focus(event.app) - @self.bindings.add('s-tab') - @self.bindings.add('up') - @self.bindings.add('right') + @self.bindings.add('left') # noqa: F811 def _(event): - new_focus = self._summary.focus_previous() - self._details.set_focus(new_focus) - - self._screen_container = HSplit( - [Window(height=1)] + - self._summary.containers + - [Window(height=1), Window(char='-', height=1), Window(height=1)] + - [self._details.container] + - [self._bottom_toolbar] - ) + self._summary.gain_focus(event.app) + + self._screen_container = HSplit([ + VSplit([ + self._summary.container, + Window(width=1), + VerticalLine(), + Window(width=1), + self._details.container + ]), + self._bottom_toolbar + ]) self.main_container = FloatContainer( self._screen_container, floats=[] ) + def on_mount(self, app): + self._summary.gain_focus(app) + + def stop(self): + self._details.stop() + def _toggle_help(self): if self.main_container.floats: self.main_container.floats = [] diff --git a/sml_sync/screens/help.py b/sml_sync/screens/help.py index 259f50c..e52a9b5 100644 --- a/sml_sync/screens/help.py +++ b/sml_sync/screens/help.py @@ -1,7 +1,5 @@ -from prompt_toolkit.layout import HSplit -from prompt_toolkit.layout.containers import Float, Window -from prompt_toolkit.layout.controls import FormattedTextControl +from prompt_toolkit.layout import HSplit, Window, FormattedTextControl, Float from prompt_toolkit.widgets import Frame diff --git a/sml_sync/screens/humanize.py b/sml_sync/screens/humanize.py index 0d69458..6cd5ebd 100644 --- a/sml_sync/screens/humanize.py +++ b/sml_sync/screens/humanize.py @@ -123,3 +123,24 @@ def naturaltime(value, future=False, months=True): return "now" return ago % delta + + +suffixes = ('kB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB') + + +def naturalsize(value, format='%.1f'): + """Format a number of byteslike a human readable filesize (eg. 10 kB) """ + + base = 1024 + bytes = float(value) + + if bytes == 1: + return '1 Byte' + elif bytes < base: + return '%d Bytes' % bytes + + for i, s in enumerate(suffixes): + unit = base ** (i+2) + if bytes < unit: + return (format + ' %s') % ((base * bytes / unit), s) + return (format + ' %s') % ((base * bytes / unit), s) diff --git a/sml_sync/screens/sync.py b/sml_sync/screens/sync.py index 316d022..78886d6 100644 --- a/sml_sync/screens/sync.py +++ b/sml_sync/screens/sync.py @@ -3,10 +3,8 @@ import time from enum import Enum -from prompt_toolkit.application.current import get_app -from prompt_toolkit.layout import HSplit -from prompt_toolkit.layout.containers import Window -from prompt_toolkit.layout.controls import FormattedTextControl +from prompt_toolkit.application import get_app +from prompt_toolkit.layout import HSplit, Window, FormattedTextControl from .base import BaseScreen from .loading import LoadingIndicator diff --git a/sml_sync/screens/walking_trees.py b/sml_sync/screens/walking_trees.py index b3042ec..993799b 100644 --- a/sml_sync/screens/walking_trees.py +++ b/sml_sync/screens/walking_trees.py @@ -3,10 +3,8 @@ import time from enum import Enum -from prompt_toolkit.application.current import get_app -from prompt_toolkit.layout import HSplit -from prompt_toolkit.layout.containers import Window -from prompt_toolkit.layout.controls import FormattedTextControl +from prompt_toolkit.application import get_app +from prompt_toolkit.layout import HSplit, Window, FormattedTextControl from ..pubsub import Messages from .base import BaseScreen diff --git a/sml_sync/screens/watch_sync.py b/sml_sync/screens/watch_sync.py index 4a6b4a0..0b8f6b4 100644 --- a/sml_sync/screens/watch_sync.py +++ b/sml_sync/screens/watch_sync.py @@ -4,11 +4,11 @@ import time from datetime import datetime -from prompt_toolkit.application.current import get_app +from prompt_toolkit.application import get_app from prompt_toolkit.key_binding import KeyBindings -from prompt_toolkit.layout import HSplit, VSplit -from prompt_toolkit.layout.containers import FloatContainer, Window -from prompt_toolkit.layout.controls import FormattedTextControl +from prompt_toolkit.layout import ( + HSplit, VSplit, FloatContainer, Window, FormattedTextControl +) from . import humanize from ..models import ChangeEventType diff --git a/sml_sync/sync.py b/sml_sync/sync.py index 026f586..0d576d1 100644 --- a/sml_sync/sync.py +++ b/sml_sync/sync.py @@ -113,7 +113,7 @@ def _rsync_list(self, path, rsync_opts=None): exclude_list = self._get_exclude_list() rsync_cmd = [ 'rsync', '-a', '-e', ssh_cmd, '--itemize-changes', '--dry-run', - '--out-format', '%i||%n||%M', *exclude_list, *rsync_opts, path, + '--out-format', '%i||%n||%M||%l', *exclude_list, *rsync_opts, path, '/dev/false' ] process = _run_ssh_cmd(rsync_cmd) @@ -137,7 +137,7 @@ def _parse_rsync_list_result(self, stdout): fs_objects = [] for line in stdout.splitlines(): try: - changes, path, mtime_string = line.split('||') + changes, path, mtime_string, size_string = line.split('||') try: is_directory = changes[1] == 'd' except IndexError: @@ -150,10 +150,11 @@ def _parse_rsync_list_result(self, stdout): DirectoryAttrs(mtime) ) else: + size = int(size_string) fs_object = FsObject( path, FsObjectType.FILE, - FileAttrs(mtime) + FileAttrs(mtime, size) ) fs_objects.append(fs_object) except Exception as e: diff --git a/sml_sync/ui.py b/sml_sync/ui.py index 1924753..865b3a8 100644 --- a/sml_sync/ui.py +++ b/sml_sync/ui.py @@ -4,9 +4,7 @@ from prompt_toolkit.application import Application from prompt_toolkit.key_binding import KeyBindings, merge_key_bindings -from prompt_toolkit.layout import HSplit, Layout -from prompt_toolkit.layout.containers import Window -from prompt_toolkit.layout.controls import FormattedTextControl +from prompt_toolkit.layout import HSplit, Layout, Window, FormattedTextControl from .pubsub import Messages diff --git a/sml_sync/version.py b/sml_sync/version.py index a9f2e00..7205d67 100644 --- a/sml_sync/version.py +++ b/sml_sync/version.py @@ -1,3 +1,3 @@ -__version__ = '0.2.4' +__version__ = '0.3.0-alpha2' version = __version__ diff --git a/stubs/__init__.pyi b/stubs/__init__.pyi new file mode 100644 index 0000000..e69de29 diff --git a/stubs/prompt_toolkit/__init__.pyi b/stubs/prompt_toolkit/__init__.pyi new file mode 100644 index 0000000..e69de29 diff --git a/stubs/prompt_toolkit/application.pyi b/stubs/prompt_toolkit/application.pyi new file mode 100644 index 0000000..70c0028 --- /dev/null +++ b/stubs/prompt_toolkit/application.pyi @@ -0,0 +1,6 @@ + +class Application(object): + pass + + +def get_app() -> Application: ... diff --git a/stubs/prompt_toolkit/buffer.pyi b/stubs/prompt_toolkit/buffer.pyi new file mode 100644 index 0000000..eb55ca2 --- /dev/null +++ b/stubs/prompt_toolkit/buffer.pyi @@ -0,0 +1,13 @@ + +from typing import Optional + +from .document import Document + + +class Buffer(object): + + def __init__( + self, + document: Optional[Document] = None, + read_only: bool = False + ) -> None: ... diff --git a/stubs/prompt_toolkit/document.pyi b/stubs/prompt_toolkit/document.pyi new file mode 100644 index 0000000..72507af --- /dev/null +++ b/stubs/prompt_toolkit/document.pyi @@ -0,0 +1,10 @@ + +from typing import Optional + + +class Document(object): + + def __init__( + self, + text: str = '', + cursor_position: Optional[int] = None) -> None: ... diff --git a/stubs/prompt_toolkit/key_binding.pyi b/stubs/prompt_toolkit/key_binding.pyi new file mode 100644 index 0000000..e8fba24 --- /dev/null +++ b/stubs/prompt_toolkit/key_binding.pyi @@ -0,0 +1,15 @@ + +from typing import List, Callable, Any + + +class KeyBindingsBase(object): + pass + + +class KeyBindings(KeyBindingsBase): + def __init__(self) -> None: ... + + def add(self, key: str) -> Callable[..., Any]: ... + + +def merge_key_bindings(bindings: List[KeyBindings]) -> KeyBindingsBase: ... diff --git a/stubs/prompt_toolkit/layout.pyi b/stubs/prompt_toolkit/layout.pyi new file mode 100644 index 0000000..be04a5d --- /dev/null +++ b/stubs/prompt_toolkit/layout.pyi @@ -0,0 +1,112 @@ + +from typing import Optional, Any, List, Iterable, Union, Tuple + +from .key_binding import KeyBindings +from .buffer import Buffer + + +class UIControl(object): + + def reset(self) -> None: ... + + +class FormattedTextControl(UIControl): + + text: Union[str, List[Tuple[str, str]]] + + def __init__( + self, + text: Union[str, List[Tuple[str, str]]], + focusable: bool = False, + show_cursor: bool = False, + key_bindings: Optional[KeyBindings] = None) -> None: ... + + +class BufferControl(UIControl): + + def __init__( + self, + buffer: Optional[Buffer] = None + ) -> None: ... + + +class Margin(object): + pass + + +class ScrollbarMargin(Margin): + + def __init__(self, display_arrows: int = 2) -> None: ... + + +class Container(object): + + def reset(self) -> None: ... + + def preferred_width(self, max_available_width: int) -> int: ... + + def preferred_height( + self, + width: int, + max_available_height: int + ) -> int: ... + + +class Window(Container): + + def __init__( + self, + content: Optional[UIControl] = None, + width: Optional[int] = None, + height: Optional[int] = None, + left_margins: Optional[Iterable[Margin]] = None, + right_margins: Optional[Iterable[Margin]] = None + ) -> None: ... + + +class HSplit(Container): + + def __init__( + self, + children: List[Any], + width: Optional[int] = None, + height: Optional[int] = None + ) -> None: ... + + +class VSplit(Container): + + def __init__( + self, + children: List[Any], + width: Optional[int] = None, + height: Optional[int] = None + ) -> None: ... + + +class Float(object): + + def __init__( + self, + content: Optional[Container] + ) -> None: ... + + +class FloatContainer(Container): + + def __init__( + self, + content: Container, + floats: Iterable[Float] + ) -> None: ... + + +def to_container(container: Any) -> Container: ... + + +class Layout(object): + + def __init__( + self, + container: Container + ) -> None: ... diff --git a/stubs/prompt_toolkit/widgets.pyi b/stubs/prompt_toolkit/widgets.pyi new file mode 100644 index 0000000..b079e78 --- /dev/null +++ b/stubs/prompt_toolkit/widgets.pyi @@ -0,0 +1,16 @@ + +from .layout import Container + + +class TextArea(object): + + def __init__(self, text: str = '') -> None: ... + + +class Frame(object): + + def __init__(self, body: Container) -> None: ... + + +class VerticalLine(object): + pass