From 7104a13f6787fcea263f00835e462bdd90bf1f9b Mon Sep 17 00:00:00 2001 From: Joss Moffatt Date: Tue, 24 Oct 2023 23:48:36 +0100 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8=20Add=20terminal=20autocomplete=20(#1?= =?UTF-8?q?2)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Adding code for autocomplete * Adding documentation for autocomplete * Incrementing version * Fixing issue with test imports --- bookshelf/__init__.py | 2 +- bookshelf/__main__.py | 14 ++-- bookshelf/param_types.py | 18 +++++ bookshelf/storage.py | 5 ++ docs/astro.config.mjs | 1 + .../docs/getting-started/autocomplete.mdx | 70 +++++++++++++++++++ .../docs/getting-started/installation.mdx | 1 - test/test_param_types.py | 44 ++++++++++++ test/test_storage.py | 26 +++++++ 9 files changed, 173 insertions(+), 8 deletions(-) create mode 100644 bookshelf/param_types.py create mode 100644 docs/src/content/docs/getting-started/autocomplete.mdx create mode 100644 test/test_param_types.py diff --git a/bookshelf/__init__.py b/bookshelf/__init__.py index 3dc1f76..d3ec452 100644 --- a/bookshelf/__init__.py +++ b/bookshelf/__init__.py @@ -1 +1 @@ -__version__ = "0.1.0" +__version__ = "0.2.0" diff --git a/bookshelf/__main__.py b/bookshelf/__main__.py index f655342..92da693 100644 --- a/bookshelf/__main__.py +++ b/bookshelf/__main__.py @@ -7,9 +7,11 @@ from bookshelf.models import Chapter, Story from bookshelf.exceptions import StoryAlreadyExistsException, StoryNotFoundException, ChapterInProgressException, \ StoryAlreadyFinishedException +from bookshelf.param_types import StoryType bookshelf_storage = BookshelfStorage() bookshelf_console = BookshelfConsole(bookshelf_storage) +story_type = StoryType(bookshelf_storage) def entry_point(): @@ -26,7 +28,7 @@ def bookshelf(): @bookshelf.command(name='create') -@click.argument('story_name') +@click.argument('story_name', type=story_type) @click.option('-f', '--force', type=str, @@ -64,7 +66,7 @@ def create_story_entry(story_name: str, force: bool, tags: str, start_chapter: b @bookshelf.command(name='start') -@click.argument('story_name') +@click.argument('story_name', type=story_type) def start_chapter_entry(story_name): """Start a new chapter for a story on your bookshelf""" try: @@ -90,7 +92,7 @@ def start_chapter_entry(story_name): @bookshelf.command(name='stop') -@click.argument('story_name') +@click.argument('story_name', type=story_type) def stop_chapter_entry(story_name): """Stop the current chapter of a story on your bookshelf""" @@ -108,7 +110,7 @@ def stop_chapter_entry(story_name): @bookshelf.command(name='finish') -@click.argument('story_name') +@click.argument('story_name', type=story_type) def finish_story_entry(story_name): """Finish writing a story on your bookshelf""" try: @@ -136,7 +138,7 @@ def list_stories_entry(with_tags: str): @bookshelf.command(name='rm') -@click.argument('story_name') +@click.argument('story_name', type=story_type) def remove_story_entry(story_name): """Remove a story from your bookshelf""" try: @@ -149,7 +151,7 @@ def remove_story_entry(story_name): @bookshelf.command(name='info') -@click.argument('story_name') +@click.argument('story_name', type=story_type) def story_info_entry(story_name: str): """Displays the information for a given story on your bookshelf""" try: diff --git a/bookshelf/param_types.py b/bookshelf/param_types.py new file mode 100644 index 0000000..680c4e2 --- /dev/null +++ b/bookshelf/param_types.py @@ -0,0 +1,18 @@ +from click import ParamType +from click.shell_completion import CompletionItem + +from bookshelf.storage import BookshelfStorage + + +class StoryType(ParamType): + name = "story" + + def __init__(self, bookshelf_storage: BookshelfStorage) -> None: + super().__init__() + self.bookshelf_storage = bookshelf_storage + + def shell_complete(self, ctx, param, incomplete): + return [ + CompletionItem(story.name, help=story.tags) + for story in self.bookshelf_storage.get_all_stories_matching_incomplete_name(incomplete) + ] diff --git a/bookshelf/storage.py b/bookshelf/storage.py index c5caeaf..2d7e635 100644 --- a/bookshelf/storage.py +++ b/bookshelf/storage.py @@ -58,3 +58,8 @@ def get_all_stories(self) -> Generator[Story, None, None]: except json.JSONDecodeError: # TODO: Maybe add some logs here that can be enabled with --logs pass + + def get_all_stories_matching_incomplete_name(self, incomplete_name: str) -> Generator[Story, None, None]: + for story in self.get_all_stories(): + if incomplete_name in story.name: + yield story diff --git a/docs/astro.config.mjs b/docs/astro.config.mjs index 7cbf5cb..f00fc4a 100644 --- a/docs/astro.config.mjs +++ b/docs/astro.config.mjs @@ -17,6 +17,7 @@ export default defineConfig({ items: [ // Each item here is one entry in the navigation menu. { label: 'Installation', link: '/getting-started/installation/' }, + { label: 'Enabling Autocomplete', link: '/getting-started/autocomplete/' }, { label: 'Glossary', link: '/getting-started/glossary/' }, ], }, diff --git a/docs/src/content/docs/getting-started/autocomplete.mdx b/docs/src/content/docs/getting-started/autocomplete.mdx new file mode 100644 index 0000000..e5f37bb --- /dev/null +++ b/docs/src/content/docs/getting-started/autocomplete.mdx @@ -0,0 +1,70 @@ +--- +title: Enabling Autocomplete +description: A guide to setting up autocomplete with bookshelf +--- +import { Tabs, TabItem } from '@astrojs/starlight/components'; + + +All of this information is taken from the [click documentation](https://click.palletsprojects.com/en/8.1.x/shell-completion/), and you should read it if you want to know more. +In order for completion to be used, the user needs to register a special function with their shell. +The script is different for every shell, and Click will output it for bookshelf when called with `_BOOKSHELF_COMPLETE` set to `{shell}_source`. +The built-in shells are bash, zsh, and fish. + +## Using Eval + + + + Add this to ~/.bashrc: + ```bash + eval "$(_BOOKSHELF_COMPLETE=bash_source bookshelf)" + ``` + + + Add this to ~/.zshrc: + ```bash + eval "$(_BOOKSHELF_COMPLETE=zsh_source bookshelf)" + ``` + + + + Add this to ~/.config/fish/completions/bookshelf.fish: + ```bash + _BOOKSHELF_COMPLETE=fish_source bookshelf | source + ``` + This is the same file used for the activation script method below. For Fish it’s probably always easier to use that method. + + + +## Using an Activation Script +Using eval means that the command is invoked and evaluated every time a shell is started, which can delay shell responsiveness. +To speed it up, write the generated script to a file, then source that. +You can generate the files ahead of time and distribute them with your program to save your users a step. + + + + Save the script somewhere. + ```bash + _BOOKSHELF_COMPLETE=bash_source bookshelf > ~/.bookshelf-complete.bash + ``` + Source the file in ~/.bashrc. + ```bash + . ~/.bookshelf-complete.bash + ``` + + + Save the script somewhere. + ```bash + _BOOKSHELF_COMPLETE=zsh_source bookshelf > ~/.bookshelf-complete.zsh + ``` + Source the file in ~/.zshrc. + ```bash + . ~/.bookshelf-complete.zsh + ``` + + + Save the script to ~/.config/fish/completions/bookshelf.fish: + ```bash + _BOOKSHELF_COMPLETE=fish_source bookshelf > ~/.config/fish/completions/bookshelf.fish + ``` + + \ No newline at end of file diff --git a/docs/src/content/docs/getting-started/installation.mdx b/docs/src/content/docs/getting-started/installation.mdx index 3050bed..84d7869 100644 --- a/docs/src/content/docs/getting-started/installation.mdx +++ b/docs/src/content/docs/getting-started/installation.mdx @@ -4,7 +4,6 @@ description: A guide to install bookshelf --- import { Tabs, TabItem } from '@astrojs/starlight/components'; -## Prerequisites ## Using a Package Manager You can easily add this project to your own by using your favorite project manager. Here are some examples for popular build tools: diff --git a/test/test_param_types.py b/test/test_param_types.py new file mode 100644 index 0000000..fbca33d --- /dev/null +++ b/test/test_param_types.py @@ -0,0 +1,44 @@ +from unittest.mock import MagicMock +from assertpy import assert_that +from click.shell_completion import CompletionItem + +from bookshelf.param_types import StoryType +from test.utils.storage_utils import create_test_story + + +def test_story_type_init(): + # Create a MagicMock object for BookshelfStorage + bookshelf_storage = MagicMock() + story_type = StoryType(bookshelf_storage) + assert_that(story_type.name).is_equal_to('story') + assert_that(story_type.bookshelf_storage).is_equal_to(bookshelf_storage) + + +def test_story_type_shell_complete_filters_stories(): + # Create a MagicMock object for BookshelfStorage + bookshelf_storage = MagicMock() + story_type = StoryType(bookshelf_storage) + + # Mock the behavior of bookshelf_storage.get_all_stories_matching_incomplete_name + bookshelf_storage.get_all_stories_matching_incomplete_name.return_value = [ + create_test_story('SampleStory1'), + create_test_story('SampleStory2') + ] + + # Create MagicMock objects for ctx, param, and incomplete + ctx = MagicMock() + param = MagicMock() + incomplete = 'Sam' + + # Call the shell_complete method + completions = story_type.shell_complete(ctx, param, incomplete) + + # Assert that the completions are as expected + expected_completion_1 = CompletionItem('SampleStory1') + expected_completion_2 = CompletionItem('SampleStory2') + + assert_that(len(completions)).is_equal_to(2) + + for completion in completions: + assert_that(completion).is_instance_of(CompletionItem) + assert_that(completion.value).is_in(expected_completion_1.value, expected_completion_2.value) diff --git a/test/test_storage.py b/test/test_storage.py index d4ee95f..011ee83 100644 --- a/test/test_storage.py +++ b/test/test_storage.py @@ -158,3 +158,29 @@ def test_get_all_stories_with_non_json(bookshelf_storage): stories = list(bookshelf_storage.get_all_stories()) assert_that(stories).is_empty() + + +def test_get_all_stories_matching_incomplete_name_filters_out_value(bookshelf_storage, mock_home): + test_story_1 = create_test_story(name='Test1') + test_story_2 = create_test_story(name='Test2') + test_story_3 = create_test_story(name='Test3') + test_story_4 = create_test_story(name='does_not_match') + test_stories = [test_story_1, test_story_2, test_story_3, test_story_4] + + os.walk = MagicMock() + os.walk.return_value = [(BOOKSHELF_TASK_DIR, + [], + [f'{story.name}.json' for story in test_stories])] + + def side_effect(*args, **kwargs): + # Use a generator to yield contents for each call + for story in test_stories: + yield str(story.to_json()).replace('\'', '"') + + m_open = mock_open() + m_open().read.side_effect = side_effect() + + with patch('builtins.open', m_open): + stories = list(bookshelf_storage.get_all_stories_matching_incomplete_name('Test')) + + assert_that(stories).contains_only(test_story_1, test_story_2, test_story_3)