Skip to content

Commit

Permalink
✨ Add terminal autocomplete (#12)
Browse files Browse the repository at this point in the history
* Adding code for autocomplete

* Adding documentation for autocomplete

* Incrementing version

* Fixing issue with test imports
  • Loading branch information
jossmoff authored Oct 24, 2023
1 parent a760476 commit 7104a13
Show file tree
Hide file tree
Showing 9 changed files with 173 additions and 8 deletions.
2 changes: 1 addition & 1 deletion bookshelf/__init__.py
Original file line number Diff line number Diff line change
@@ -1 +1 @@
__version__ = "0.1.0"
__version__ = "0.2.0"
14 changes: 8 additions & 6 deletions bookshelf/__main__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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():
Expand All @@ -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,
Expand Down Expand Up @@ -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:
Expand All @@ -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"""

Expand All @@ -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:
Expand Down Expand Up @@ -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:
Expand All @@ -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:
Expand Down
18 changes: 18 additions & 0 deletions bookshelf/param_types.py
Original file line number Diff line number Diff line change
@@ -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)
]
5 changes: 5 additions & 0 deletions bookshelf/storage.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
1 change: 1 addition & 0 deletions docs/astro.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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/' },
],
},
Expand Down
70 changes: 70 additions & 0 deletions docs/src/content/docs/getting-started/autocomplete.mdx
Original file line number Diff line number Diff line change
@@ -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

<Tabs>
<TabItem label="Bash">
Add this to ~/.bashrc:
```bash
eval "$(_BOOKSHELF_COMPLETE=bash_source bookshelf)"
```
</TabItem>
<TabItem label="Zsh">
Add this to ~/.zshrc:
```bash
eval "$(_BOOKSHELF_COMPLETE=zsh_source bookshelf)"
```

</TabItem>
<TabItem label="Fish">
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.
</TabItem>
</Tabs>

## 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.

<Tabs>
<TabItem label="Bash">
Save the script somewhere.
```bash
_BOOKSHELF_COMPLETE=bash_source bookshelf > ~/.bookshelf-complete.bash
```
Source the file in ~/.bashrc.
```bash
. ~/.bookshelf-complete.bash
```
</TabItem>
<TabItem label="Zsh">
Save the script somewhere.
```bash
_BOOKSHELF_COMPLETE=zsh_source bookshelf > ~/.bookshelf-complete.zsh
```
Source the file in ~/.zshrc.
```bash
. ~/.bookshelf-complete.zsh
```
</TabItem>
<TabItem label="Fish">
Save the script to ~/.config/fish/completions/bookshelf.fish:
```bash
_BOOKSHELF_COMPLETE=fish_source bookshelf > ~/.config/fish/completions/bookshelf.fish
```
</TabItem>
</Tabs>
1 change: 0 additions & 1 deletion docs/src/content/docs/getting-started/installation.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
44 changes: 44 additions & 0 deletions test/test_param_types.py
Original file line number Diff line number Diff line change
@@ -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)
26 changes: 26 additions & 0 deletions test/test_storage.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)

0 comments on commit 7104a13

Please sign in to comment.