From 60ec2d2455205a82b4714eb6cac76aa4eea2c42c Mon Sep 17 00:00:00 2001 From: Joss Moffatt Date: Sat, 13 Jan 2024 13:38:36 +0000 Subject: [PATCH] =?UTF-8?q?=F0=9F=8E=A8=20Use=20Ruff=20Formatter=20(#42)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * :art: Applying Ruff linting * :construction_worker: Updating CI/CD to use Ruff * :art: Add Ruff formatter badge --- .github/workflows/cicd.yaml | 4 +- README.md | 4 ++ bookshelf/__main__.py | 121 +++++++++++++++++++++--------------- bookshelf/console.py | 42 ++++++++----- bookshelf/exceptions.py | 32 +++++----- bookshelf/models.py | 109 ++++++++++++++++++++------------ bookshelf/param_types.py | 4 +- bookshelf/storage.py | 22 ++++--- requirements-dev.txt | 4 +- setup.cfg | 13 ++-- setup.py | 2 +- test/test_exceptions.py | 51 ++++++++------- test/test_models.py | 52 +++++++++------- test/test_param_types.py | 16 ++--- test/test_storage.py | 96 ++++++++++++++-------------- test/utils/chapter_utils.py | 4 +- test/utils/storage_utils.py | 66 +++++++++++--------- 17 files changed, 375 insertions(+), 267 deletions(-) diff --git a/.github/workflows/cicd.yaml b/.github/workflows/cicd.yaml index 5b0c38c..5c03fb9 100644 --- a/.github/workflows/cicd.yaml +++ b/.github/workflows/cicd.yaml @@ -22,8 +22,8 @@ jobs: - name: 📦 Install dependencies run: pip install -r requirements-dev.txt # Replace with your requirements file - - name: ❄️ Run Flake8 - run: flake8 . + - name: 💻 Run Ruff checks + run: ruff check . - name: 🧪 Run unit tests run: pytest --cov diff --git a/README.md b/README.md index c1de7ba..933cb25 100644 --- a/README.md +++ b/README.md @@ -32,6 +32,10 @@ + + + Ruff +

## 🔎 Tracking your productivity has never been easier diff --git a/bookshelf/__main__.py b/bookshelf/__main__.py index 44b7b96..b42e702 100644 --- a/bookshelf/__main__.py +++ b/bookshelf/__main__.py @@ -5,8 +5,13 @@ from bookshelf.console import BookshelfConsole from bookshelf.storage import BookshelfStorage from bookshelf.models import Chapter, Story -from bookshelf.exceptions import StoryAlreadyExistsException, StoryNotFoundException, ChapterInProgressException, \ - StoryAlreadyFinishedException, ChapterNotInProgressException +from bookshelf.exceptions import ( + StoryAlreadyExistsException, + StoryNotFoundException, + ChapterInProgressException, + StoryAlreadyFinishedException, + ChapterNotInProgressException, +) from bookshelf.param_types import StoryType bookshelf_storage = BookshelfStorage() @@ -18,7 +23,7 @@ def entry_point(): try: bookshelf() except (Exception, KeyboardInterrupt) as exception: - bookshelf_console.print(exception.terminal_message, style='red') + bookshelf_console.print(exception.terminal_message, style="red") exit(0) @@ -27,28 +32,34 @@ def bookshelf(): """📚 Bookshelf - A CLI tool to tracking your stories in the SDLC""" -@bookshelf.command(name='create') -@click.argument('story_name', type=story_type) -@click.option('-f', - '--force', - type=str, - is_flag=True, - help='Usually, the command will not start a story that already exists. ' - 'This flag disables these checks, use it with care.') -@click.option('-t', - '--tags', - type=str, - help='Comma-separated list of tags that you want to apply to the story.') -@click.option('--start-chapter', - type=bool, - is_flag=True, - show_default=True, - default=False, - help='Optionally start the first chapter of the story you are creating right away.') +@bookshelf.command(name="create") +@click.argument("story_name", type=story_type) +@click.option( + "-f", + "--force", + type=str, + is_flag=True, + help="Usually, the command will not start a story that already exists. " + "This flag disables these checks, use it with care.", +) +@click.option( + "-t", + "--tags", + type=str, + help="Comma-separated list of tags that you want to apply to the story.", +) +@click.option( + "--start-chapter", + type=bool, + is_flag=True, + show_default=True, + default=False, + help="Optionally start the first chapter of the story you are creating right away.", +) def create_story_entry(story_name: str, force: bool, tags: str, start_chapter: bool): """Create a new story for your bookshelf""" try: - tags_list = [tag.strip() for tag in tags.split(',')] if tags is not None else [] + tags_list = [tag.strip() for tag in tags.split(",")] if tags is not None else [] if bookshelf_storage.story_exists(story_name) and not force: raise StoryAlreadyExistsException(story_name) @@ -61,11 +72,11 @@ def create_story_entry(story_name: str, force: bool, tags: str, start_chapter: b bookshelf_storage.save_story(story) bookshelf_console.render_story_panel(story) except KeyboardInterrupt: - bookshelf_console.print(f'📕 Story \'{story_name}\' has been created!') + bookshelf_console.print(f"📕 Story '{story_name}' has been created!") -@bookshelf.command(name='start') -@click.argument('story_name', type=story_type) +@bookshelf.command(name="start") +@click.argument("story_name", type=story_type) def start_chapter_entry(story_name): """Start a new chapter for a story on your bookshelf""" try: @@ -87,11 +98,11 @@ def start_chapter_entry(story_name): bookshelf_storage.save_story(story) bookshelf_console.render_story_panel(story) except KeyboardInterrupt: - bookshelf_console.print(f'📖 A new chapter in \'{story_name}\' has been started!') + bookshelf_console.print(f"📖 A new chapter in '{story_name}' has been started!") -@bookshelf.command(name='stop') -@click.argument('story_name', type=story_type) +@bookshelf.command(name="stop") +@click.argument("story_name", type=story_type) def stop_chapter_entry(story_name): """Stop the current chapter of a story on your bookshelf""" @@ -108,8 +119,8 @@ def stop_chapter_entry(story_name): pass -@bookshelf.command(name='finish') -@click.argument('story_name', type=story_type) +@bookshelf.command(name="finish") +@click.argument("story_name", type=story_type) def finish_story_entry(story_name): """Finish writing a story on your bookshelf""" try: @@ -125,32 +136,40 @@ def finish_story_entry(story_name): pass -@bookshelf.command(name='ls') -@click.option('--with-tags', type=str, help='Comma-separated list of tags that you wish to filter in.') +@bookshelf.command(name="ls") +@click.option( + "--with-tags", + type=str, + help="Comma-separated list of tags that you wish to filter in.", +) def list_stories_entry(with_tags: str): """List all the current stories on your bookshelf""" try: - tags_to_filter_by = [tag.strip() for tag in with_tags.split(',')] if with_tags is not None else [] + tags_to_filter_by = ( + [tag.strip() for tag in with_tags.split(",")] + if with_tags is not None + else [] + ) bookshelf_console.render_all_stories_table(tags_to_filter_by=tags_to_filter_by) except KeyboardInterrupt: pass -@bookshelf.command(name='rm') -@click.argument('story_name', type=story_type) +@bookshelf.command(name="rm") +@click.argument("story_name", type=story_type) def remove_story_entry(story_name): """Remove a story from your bookshelf""" try: story = bookshelf_storage.load_story(story_name) - exit_live_message = 'Press [bold]CTRL+C[/bold] to delete' + exit_live_message = "Press [bold]CTRL+C[/bold] to delete" bookshelf_console.render_story_panel(story, exit_live_message=exit_live_message) except KeyboardInterrupt: bookshelf_storage.delete_story(story_name) - bookshelf_console.print(f'🗑️ Story \'{story_name}\' has been deleted!') + bookshelf_console.print(f"🗑️ Story '{story_name}' has been deleted!") -@bookshelf.command(name='info') -@click.argument('story_name', type=story_type) +@bookshelf.command(name="info") +@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: @@ -160,8 +179,8 @@ def story_info_entry(story_name: str): pass -@bookshelf.command(name='cancel') -@click.argument('story_name', type=story_type) +@bookshelf.command(name="cancel") +@click.argument("story_name", type=story_type) def cancel_chapter(story_name): """Cancel the current chapter of a story on your bookshelf""" try: @@ -178,26 +197,28 @@ def cancel_chapter(story_name): bookshelf_storage.save_story(story) bookshelf_console.render_story_panel(story) except KeyboardInterrupt: - bookshelf_console.print(f'❌ Current chapter in \'{story_name}\' has been cancelled!') + bookshelf_console.print( + f"❌ Current chapter in '{story_name}' has been cancelled!" + ) -@bookshelf.command(name='tag') -@click.argument('story_name', type=story_type) -@click.argument('tag', type=str) +@bookshelf.command(name="tag") +@click.argument("story_name", type=story_type) +@click.argument("tag", type=str) def tag_story(story_name: str, tag: str): """Add a tag to a story on your bookshelf""" story = bookshelf_storage.load_story(story_name) story.add_tag(tag) - bookshelf_console.print(f'🏷️ The tag \'{tag}\' has been added to \'{story_name}\'!') + bookshelf_console.print(f"🏷️ The tag '{tag}' has been added to '{story_name}'!") bookshelf_storage.save_story(story) -@bookshelf.command(name='untag') -@click.argument('story_name', type=story_type) -@click.argument('tag', type=str) +@bookshelf.command(name="untag") +@click.argument("story_name", type=story_type) +@click.argument("tag", type=str) def untag_story(story_name: str, tag: str): """Remove a tag from a story on your bookshelf""" story = bookshelf_storage.load_story(story_name) story.remove_tag(tag) - bookshelf_console.print(f'🏷️ The tag \'{tag}\' has been removed from \'{story_name}\'!') + bookshelf_console.print(f"🏷️ The tag '{tag}' has been removed from '{story_name}'!") bookshelf_storage.save_story(story) diff --git a/bookshelf/console.py b/bookshelf/console.py index 0ef8dab..78dd568 100644 --- a/bookshelf/console.py +++ b/bookshelf/console.py @@ -17,26 +17,36 @@ def __init__(self, bookshelf_storage: BookshelfStorage): super().__init__() self.bookshelf_storage = bookshelf_storage - def render_all_stories_table(self, tags_to_filter_by: Optional[list[str]] = None) -> None: + def render_all_stories_table( + self, tags_to_filter_by: Optional[list[str]] = None + ) -> None: table = Table(title="📖 Stories 📖", box=box.DOUBLE_EDGE) - table.add_column('✏️ Name', style="cyan", justify="center") - table.add_column('🗓️ Start Date', style="white", justify="center") - table.add_column('🏷️ Tags', style="yellow", justify="center") + table.add_column("✏️ Name", style="cyan", justify="center") + table.add_column("🗓️ Start Date", style="white", justify="center") + table.add_column("🏷️ Tags", style="yellow", justify="center") for story in self.bookshelf_storage.get_all_stories(): - if len(tags_to_filter_by) == 0 or any(tag in story.tags for tag in tags_to_filter_by): - table.add_row(f'{story.name}', - f'{story.start_date.day}/{story.start_date.month}/{story.start_date.year}', - f'{story.tags}') + if len(tags_to_filter_by) == 0 or any( + tag in story.tags for tag in tags_to_filter_by + ): + table.add_row( + f"{story.name}", + f"{story.start_date.day}/{story.start_date.month}/{story.start_date.year}", + f"{story.tags}", + ) self.print(table) - def render_story_panel(self, story, exit_live_message: Optional[str] = None, should_clear: bool = True) -> None: + def render_story_panel( + self, story, exit_live_message: Optional[str] = None, should_clear: bool = True + ) -> None: if exit_live_message is None: - exit_live_message = 'Press [bold]CTRL+C[/bold] to exit' + exit_live_message = "Press [bold]CTRL+C[/bold] to exit" - with Live(self._get_story_panel(story, exit_live_message), - transient=should_clear, - refresh_per_second=1) as live_panel: + with Live( + self._get_story_panel(story, exit_live_message), + transient=should_clear, + refresh_per_second=1, + ) as live_panel: while True: live_panel.update(self._get_story_panel(story, exit_live_message)) time.sleep(0.4) @@ -46,5 +56,9 @@ def render_story_panel(self, story, exit_live_message: Optional[str] = None, sho @staticmethod def _get_story_panel(story: Story, exit_live_message: str): - panel = Panel(story.to_renderable(exit_live_message=exit_live_message), title='📖 Story 📖', expand=True) + panel = Panel( + story.to_renderable(exit_live_message=exit_live_message), + title="📖 Story 📖", + expand=True, + ) return Padding(panel, 1) diff --git a/bookshelf/exceptions.py b/bookshelf/exceptions.py index adc9eda..fef5ac1 100644 --- a/bookshelf/exceptions.py +++ b/bookshelf/exceptions.py @@ -1,55 +1,57 @@ class BookshelfBaseException(Exception): - def __init__(self, message: str): super().__init__(message) class BookshelfStoryException(BookshelfBaseException): - def __init__(self, story_name: str, message_template: str, hint_template: str): - self.terminal_message = self._construct_exception_message(story_name, message_template, hint_template) + self.terminal_message = self._construct_exception_message( + story_name, message_template, hint_template + ) super().__init__(message_template.format(story_name)) @staticmethod - def _construct_exception_message(story_name: str, message_template: str, hint_template: str) -> str: - return f'❌{message_template}\n💡{hint_template}'.format(story_name, story_name) + def _construct_exception_message( + story_name: str, message_template: str, hint_template: str + ) -> str: + return f"❌{message_template}\n💡{hint_template}".format(story_name, story_name) class StoryNotFoundException(BookshelfStoryException): - MESSAGE_TEMPLATE = 'Could not find story with name [bold]{}[/bold].' - HINT_TEMPLATE = 'You can create one with [bold]bookshelf create {}[/bold].' + MESSAGE_TEMPLATE = "Could not find story with name [bold]{}[/bold]." + HINT_TEMPLATE = "You can create one with [bold]bookshelf create {}[/bold]." def __init__(self, story_name): super().__init__(story_name, self.MESSAGE_TEMPLATE, self.HINT_TEMPLATE) class StoryAlreadyExistsException(BookshelfStoryException): - MESSAGE_TEMPLATE = 'The story [bold]{}[/bold] already exists.' - HINT_TEMPLATE = 'Use [bold]bookshelf create {} --force [/bold] to override.' + MESSAGE_TEMPLATE = "The story [bold]{}[/bold] already exists." + HINT_TEMPLATE = "Use [bold]bookshelf create {} --force [/bold] to override." def __init__(self, story_name): super().__init__(story_name, self.MESSAGE_TEMPLATE, self.HINT_TEMPLATE) class StoryAlreadyFinishedException(BookshelfStoryException): - MESSAGE_TEMPLATE = 'The story [bold]{}[/bold] is already finished.' - HINT_TEMPLATE = 'Use [bold]bookshelf create {} [/bold] a new story.' + MESSAGE_TEMPLATE = "The story [bold]{}[/bold] is already finished." + HINT_TEMPLATE = "Use [bold]bookshelf create {} [/bold] a new story." def __init__(self, story_name): super().__init__(story_name, self.MESSAGE_TEMPLATE, self.HINT_TEMPLATE) class ChapterInProgressException(BookshelfStoryException): - MESSAGE_TEMPLATE = 'The current chapter in [bold]{}[/bold] is in progress.' - HINT_TEMPLATE = 'Use [bold]bookshelf stop {}[/bold] before starting a new one.' + MESSAGE_TEMPLATE = "The current chapter in [bold]{}[/bold] is in progress." + HINT_TEMPLATE = "Use [bold]bookshelf stop {}[/bold] before starting a new one." def __init__(self, story_name): super().__init__(story_name, self.MESSAGE_TEMPLATE, self.HINT_TEMPLATE) class ChapterNotInProgressException(BookshelfStoryException): - MESSAGE_TEMPLATE = 'The current chapter in {} is not in progress.' - HINT_TEMPLATE = 'Use [bold]bookshelf start {}[/bold] to start one.' + MESSAGE_TEMPLATE = "The current chapter in {} is not in progress." + HINT_TEMPLATE = "Use [bold]bookshelf start {}[/bold] to start one." def __init__(self, story_name): super().__init__(story_name, self.MESSAGE_TEMPLATE, self.HINT_TEMPLATE) diff --git a/bookshelf/models.py b/bookshelf/models.py index 3678102..2530545 100644 --- a/bookshelf/models.py +++ b/bookshelf/models.py @@ -1,11 +1,13 @@ from datetime import datetime, timedelta from typing import Optional, List -from bookshelf.exceptions import ChapterNotInProgressException, StoryAlreadyFinishedException +from bookshelf.exceptions import ( + ChapterNotInProgressException, + StoryAlreadyFinishedException, +) class Chapter: - def __init__(self, start_time: datetime, end_time: Optional[datetime] = None): self.start_time: datetime = start_time self.end_time: datetime = end_time @@ -20,28 +22,43 @@ def in_progress(self) -> bool: def to_json(self) -> dict: return { - 'start_time': self.start_time.isoformat(), - 'end_time': self.end_time.isoformat() if self.end_time is not None else None + "start_time": self.start_time.isoformat(), + "end_time": self.end_time.isoformat() + if self.end_time is not None + else None, } @classmethod - def from_json(cls, data: dict) -> 'Chapter': - start_time = datetime.fromisoformat(data['start_time']) if data['start_time'] is not None else None - end_time = datetime.fromisoformat(data['end_time']) if data['end_time'] is not None else None + def from_json(cls, data: dict) -> "Chapter": + start_time = ( + datetime.fromisoformat(data["start_time"]) + if data["start_time"] is not None + else None + ) + end_time = ( + datetime.fromisoformat(data["end_time"]) + if data["end_time"] is not None + else None + ) return cls(start_time, end_time) - def __eq__(self, other: 'Chapter') -> bool: + def __eq__(self, other: "Chapter") -> bool: if isinstance(other, Chapter): - return self.start_time == other.start_time and self.end_time == other.end_time + return ( + self.start_time == other.start_time and self.end_time == other.end_time + ) return False class Story: - - def __init__(self, name: str, start_date: datetime, - end_date: Optional[datetime] = None, - chapters: Optional[list[Chapter]] = None, - tags: Optional[list[str]] = None): + def __init__( + self, + name: str, + start_date: datetime, + end_date: Optional[datetime] = None, + chapters: Optional[list[Chapter]] = None, + tags: Optional[list[str]] = None, + ): if chapters is None: chapters = [] if tags is None: @@ -93,44 +110,56 @@ def is_finished(self) -> bool: def to_json(self) -> dict: return { - 'name': self.name, - 'start_date': self.start_date.isoformat(), - 'end_date': self.end_date.isoformat() if self.end_date else None, - 'tags': self.tags, - 'chapters': [chapter.to_json() for chapter in self.chapters] + "name": self.name, + "start_date": self.start_date.isoformat(), + "end_date": self.end_date.isoformat() if self.end_date else None, + "tags": self.tags, + "chapters": [chapter.to_json() for chapter in self.chapters], } @classmethod - def from_json(cls, data: dict) -> 'Story': - name = data['name'] - start_date = datetime.fromisoformat(data['start_date']) - end_date = datetime.fromisoformat(data['end_date']) if data['end_date'] else None - tags = data['tags'] - chapters = [Chapter.from_json(chapter_data) for chapter_data in data['chapters']] + def from_json(cls, data: dict) -> "Story": + name = data["name"] + start_date = datetime.fromisoformat(data["start_date"]) + end_date = ( + datetime.fromisoformat(data["end_date"]) if data["end_date"] else None + ) + tags = data["tags"] + chapters = [ + Chapter.from_json(chapter_data) for chapter_data in data["chapters"] + ] return cls(name, start_date, end_date, chapters.copy(), tags.copy()) def in_progress(self) -> bool: - return self.get_last_chapter().in_progress() if len(self.chapters) > 0 else False + return ( + self.get_last_chapter().in_progress() if len(self.chapters) > 0 else False + ) def to_renderable(self, exit_live_message: str) -> str: - name_line = f'[bold]✏️ Name:[/bold] \n {self.name}' - start_date_line = (f'[bold]🗓️ Start Date:[/bold] \n' - f' {self.start_date.day}/{self.start_date.month}/{self.start_date.year}') + name_line = f"[bold]✏️ Name:[/bold] \n {self.name}" + start_date_line = ( + f"[bold]🗓️ Start Date:[/bold] \n" + f" {self.start_date.day}/{self.start_date.month}/{self.start_date.year}" + ) elapsed_time = self.compute_elapsed_time_from_chapters() - elapsed_line = (f'[bold]⏱️ Elapsed Time:[/bold] \n' - f' {elapsed_time.seconds // 3600} hours, ' - f'{(elapsed_time.seconds // 60) % 60} minutes, ' - f'{elapsed_time.seconds % 60} seconds') - tags = f'[bold]🏷️ Tags:[/bold] \n {str(self.tags)}' - return '\n'.join((name_line, start_date_line, elapsed_line, tags, f'\n[{exit_live_message}]')) + elapsed_line = ( + f"[bold]⏱️ Elapsed Time:[/bold] \n" + f" {elapsed_time.seconds // 3600} hours, " + f"{(elapsed_time.seconds // 60) % 60} minutes, " + f"{elapsed_time.seconds % 60} seconds" + ) + tags = f"[bold]🏷️ Tags:[/bold] \n {str(self.tags)}" + return "\n".join( + (name_line, start_date_line, elapsed_line, tags, f"\n[{exit_live_message}]") + ) def __eq__(self, other): if isinstance(other, Story): return ( - self.name == other.name and - self.start_date == other.start_date and - self.end_date == other.end_date and - self.chapters == other.chapters and - self.tags == other.tags + self.name == other.name + and self.start_date == other.start_date + and self.end_date == other.end_date + and self.chapters == other.chapters + and self.tags == other.tags ) return False diff --git a/bookshelf/param_types.py b/bookshelf/param_types.py index 680c4e2..1300d17 100644 --- a/bookshelf/param_types.py +++ b/bookshelf/param_types.py @@ -14,5 +14,7 @@ def __init__(self, bookshelf_storage: BookshelfStorage) -> None: 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) + for story in self.bookshelf_storage.get_all_stories_matching_incomplete_name( + incomplete + ) ] diff --git a/bookshelf/storage.py b/bookshelf/storage.py index 2d7e635..7581926 100644 --- a/bookshelf/storage.py +++ b/bookshelf/storage.py @@ -8,8 +8,8 @@ class BookshelfStorage: - BOOKSHELF_DIR = f'{Path.home()}/.bookshelf' - BOOKSHELF_TASK_DIR = f'{BOOKSHELF_DIR}/tasks' + BOOKSHELF_DIR = f"{Path.home()}/.bookshelf" + BOOKSHELF_TASK_DIR = f"{BOOKSHELF_DIR}/tasks" def __init__(self): # TODO: Doesn't need to be a class but will probably want to in the future @@ -18,15 +18,15 @@ def __init__(self): def save_story(self, story: Story) -> None: if not os.path.exists(self.BOOKSHELF_DIR): os.makedirs(self.BOOKSHELF_TASK_DIR) - filename = os.path.join(self.BOOKSHELF_TASK_DIR, f'{story.name}.json') - with open(filename, 'w') as file: + filename = os.path.join(self.BOOKSHELF_TASK_DIR, f"{story.name}.json") + with open(filename, "w") as file: data = story.to_json() json.dump(data, file) def load_story(self, story_name: str) -> Story: story_path = self._get_story_path(story_name) if os.path.exists(story_path): - with open(story_path, 'r') as file: + with open(story_path, "r") as file: data = json.load(file) return Story.from_json(data) raise StoryNotFoundException(story_name) @@ -39,19 +39,19 @@ def delete_story(self, story_name: str) -> None: raise StoryNotFoundException(story_name) def _get_story_path(self, story_name: str) -> str: - return os.path.join(self.BOOKSHELF_TASK_DIR, f'{story_name}.json') + return os.path.join(self.BOOKSHELF_TASK_DIR, f"{story_name}.json") def story_exists(self, story_name: str) -> bool: - file_path = os.path.join(self.BOOKSHELF_TASK_DIR, f'{story_name}.json') + file_path = os.path.join(self.BOOKSHELF_TASK_DIR, f"{story_name}.json") return os.path.exists(file_path) def get_all_stories(self) -> Generator[Story, None, None]: for root, _, files in os.walk(self.BOOKSHELF_TASK_DIR): for filename in files: - if filename.endswith('.json'): + if filename.endswith(".json"): file_path = os.path.join(root, filename) - with open(file_path, 'r') as file: + with open(file_path, "r") as file: try: data = json.load(file) yield Story.from_json(data) @@ -59,7 +59,9 @@ def get_all_stories(self) -> Generator[Story, None, None]: # 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]: + 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/requirements-dev.txt b/requirements-dev.txt index 116694b..80a15ce 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -4,5 +4,5 @@ assertpy~=1.1.0 pytest==7.4.3 pytest-cov==4.1.0 setuptools==68.2.2 -flake8~=6.1.0 -mutmut~=2.4.4 \ No newline at end of file +mutmut~=2.4.4 +ruff~=0.1.13 \ No newline at end of file diff --git a/setup.cfg b/setup.cfg index 90c3254..87a3c81 100644 --- a/setup.cfg +++ b/setup.cfg @@ -26,10 +26,9 @@ exclude = tests* docs* -[flake8] -max-line-length = 120 -exclude = - .git, - __pycache__, - venv -max-complexity = 10 \ No newline at end of file +[tool.ruff] +line-length = 80 +indent-width = 4 + +# Assume Python 3.9 +target-version = "py39" \ No newline at end of file diff --git a/setup.py b/setup.py index 488b26f..5f97ff0 100644 --- a/setup.py +++ b/setup.py @@ -1,4 +1,4 @@ from setuptools import setup -if __name__ == '__main__': +if __name__ == "__main__": setup(zip_safe=False) diff --git a/test/test_exceptions.py b/test/test_exceptions.py index 6914c46..750803a 100644 --- a/test/test_exceptions.py +++ b/test/test_exceptions.py @@ -11,49 +11,58 @@ def test_story_not_found_exception(): - story_name = 'my_story_name' + story_name = "my_story_name" with pytest.raises(StoryNotFoundException) as exc_info: raise StoryNotFoundException(story_name) - assert_that(str(exc_info.value)).is_equal_to('Could not find story with name [bold]my_story_name[/bold].') + assert_that(str(exc_info.value)).is_equal_to( + "Could not find story with name [bold]my_story_name[/bold]." + ) assert_that(exc_info.value.terminal_message).is_equal_to( - f'❌Could not find story with name [bold]{story_name}[/bold].\n' - f'💡You can create one with [bold]bookshelf create {story_name}[/bold].') + f"❌Could not find story with name [bold]{story_name}[/bold].\n" + f"💡You can create one with [bold]bookshelf create {story_name}[/bold]." + ) # Test cases for StoryAlreadyExistsException def test_story_already_exists_exception(): - story_name = 'existing_story' + story_name = "existing_story" with pytest.raises(StoryAlreadyExistsException) as exc_info: - raise StoryAlreadyExistsException('existing_story') + raise StoryAlreadyExistsException("existing_story") assert_that(exc_info.value.terminal_message).is_equal_to( - f'❌The story [bold]{story_name}[/bold] already exists.\n' - f'💡Use [bold]bookshelf create {story_name} --force [/bold] to override.') + f"❌The story [bold]{story_name}[/bold] already exists.\n" + f"💡Use [bold]bookshelf create {story_name} --force [/bold] to override." + ) def test_story_already_finished_exception(): - story_name = 'finished_story' + story_name = "finished_story" with pytest.raises(StoryAlreadyFinishedException) as exc_info: - raise StoryAlreadyFinishedException('finished_story') - assert_that(str(exc_info.value)).is_equal_to('The story [bold]finished_story[/bold] is already finished.') + raise StoryAlreadyFinishedException("finished_story") + assert_that(str(exc_info.value)).is_equal_to( + "The story [bold]finished_story[/bold] is already finished." + ) assert_that(exc_info.value.terminal_message).is_equal_to( - f'❌The story [bold]{story_name}[/bold] is already finished.\n' - f'💡Use [bold]bookshelf create {story_name} [/bold] a new story.') + f"❌The story [bold]{story_name}[/bold] is already finished.\n" + f"💡Use [bold]bookshelf create {story_name} [/bold] a new story." + ) def test_chapter_in_progress_exception(): - story_name = 'story_with_in_progress_chapter' + story_name = "story_with_in_progress_chapter" with pytest.raises(ChapterInProgressException) as exc_info: - raise ChapterInProgressException('story_with_in_progress_chapter') + raise ChapterInProgressException("story_with_in_progress_chapter") assert_that(exc_info.value.terminal_message).is_equal_to( - f'❌The current chapter in [bold]{story_name}[/bold] is in progress.\n' - f'💡Use [bold]bookshelf stop {story_name}[/bold] before starting a new one.') + f"❌The current chapter in [bold]{story_name}[/bold] is in progress.\n" + f"💡Use [bold]bookshelf stop {story_name}[/bold] before starting a new one." + ) # Test cases for ChapterNotInProgressException def test_chapter_not_in_progress_exception(): - story_name = 'story_without_in_progress_chapter' + story_name = "story_without_in_progress_chapter" with pytest.raises(ChapterNotInProgressException) as exc_info: - raise ChapterNotInProgressException('story_without_in_progress_chapter') + raise ChapterNotInProgressException("story_without_in_progress_chapter") assert_that(exc_info.value.terminal_message).is_equal_to( - f'❌The current chapter in {story_name} is not in progress.\n' - f'💡Use [bold]bookshelf start {story_name}[/bold] to start one.') + f"❌The current chapter in {story_name} is not in progress.\n" + f"💡Use [bold]bookshelf start {story_name}[/bold] to start one." + ) diff --git a/test/test_models.py b/test/test_models.py index 2e650f0..c7af3dc 100644 --- a/test/test_models.py +++ b/test/test_models.py @@ -4,21 +4,21 @@ from assertpy import assert_that from bookshelf.models import Chapter, Story -from test.utils.chapter_utils import create_chapter_from_delta, create_in_progress_chapter +from test.utils.chapter_utils import ( + create_chapter_from_delta, + create_in_progress_chapter, +) -STORY_NAME = 'TEST_STORY' +STORY_NAME = "TEST_STORY" -SESSION_JSON = { - 'start_time': '2023-10-20T09:00:00', - 'end_time': '2023-10-20T10:30:00' -} +SESSION_JSON = {"start_time": "2023-10-20T09:00:00", "end_time": "2023-10-20T10:30:00"} STORY_JSON = { - 'name': STORY_NAME, - 'start_date': '2023-10-20T08:00:00', - 'end_date': '2023-10-20T12:00:00', - 'tags': ['tag1', 'tag2'], - 'chapters': [SESSION_JSON] + "name": STORY_NAME, + "start_date": "2023-10-20T08:00:00", + "end_date": "2023-10-20T12:00:00", + "tags": ["tag1", "tag2"], + "chapters": [SESSION_JSON], } @@ -50,30 +50,30 @@ def test_story_to_dict(sample_story): def test_add_new_tag_adds_tag(): story = Story.from_json(STORY_JSON) - new_tag = 'new_tag' + new_tag = "new_tag" story.add_tag(new_tag) - assert_that(story.tags).contains_only('tag1', 'tag2', new_tag) + assert_that(story.tags).contains_only("tag1", "tag2", new_tag) def test_add_duplicate_tag_does_not_add_tag(): story = Story.from_json(STORY_JSON) - existing_tag = 'tag1' + existing_tag = "tag1" story.add_tag(existing_tag) - assert_that(story.tags).contains_only('tag1', 'tag2') + assert_that(story.tags).contains_only("tag1", "tag2") def test_remove_existing_tag_removes_tag(): story = Story.from_json(STORY_JSON) - existing_tag = 'tag1' + existing_tag = "tag1" story.remove_tag(existing_tag) - assert_that(story.tags).contains_only('tag2') + assert_that(story.tags).contains_only("tag2") def test_remove_non_existing_tag_does_not_remove_tag(): story = Story.from_json(STORY_JSON) - non_existing_tag = 'non_existing_tag' + non_existing_tag = "non_existing_tag" story.remove_tag(non_existing_tag) - assert_that(story.tags).contains_only('tag1', 'tag2') + assert_that(story.tags).contains_only("tag1", "tag2") def test_story_from_dict(): @@ -81,7 +81,7 @@ def test_story_from_dict(): assert story.name == STORY_NAME assert story.start_date == datetime(2023, 10, 20, 8, 0, 0) assert story.end_date == datetime(2023, 10, 20, 12, 0, 0) - assert story.tags == ['tag1', 'tag2'] + assert story.tags == ["tag1", "tag2"] assert len(story.chapters) == 1 assert story.chapters[0].start_time == datetime(2023, 10, 20, 9, 0, 0) assert story.chapters[0].end_time == datetime(2023, 10, 20, 10, 30, 0) @@ -91,8 +91,12 @@ def test_compute_duration_from_chapters(): chapter1 = create_chapter_from_delta(timedelta(hours=1)) chapter2 = create_chapter_from_delta(timedelta(hours=1)) chapters = [chapter1, chapter2] - story = Story(STORY_NAME, datetime(2023, 1, 1, 10, 0), datetime(2023, 1, 1, 14, 0), chapters) - assert_that(story.compute_elapsed_time_from_chapters()).is_equal_to(timedelta(hours=2)) + story = Story( + STORY_NAME, datetime(2023, 1, 1, 10, 0), datetime(2023, 1, 1, 14, 0), chapters + ) + assert_that(story.compute_elapsed_time_from_chapters()).is_equal_to( + timedelta(hours=2) + ) def test_cancel_chapter(): @@ -100,6 +104,8 @@ def test_cancel_chapter(): chapter2 = create_chapter_from_delta(timedelta(hours=1)) in_progress_chapter = create_in_progress_chapter() chapters = [chapter1, chapter2, in_progress_chapter] - story = Story(STORY_NAME, datetime(2023, 1, 1, 10, 0), datetime(2023, 1, 1, 14, 0), chapters) + story = Story( + STORY_NAME, datetime(2023, 1, 1, 10, 0), datetime(2023, 1, 1, 14, 0), chapters + ) story.cancel_chapter() assert_that(story.chapters).contains_only(chapter1, chapter2) diff --git a/test/test_param_types.py b/test/test_param_types.py index fbca33d..1df1c91 100644 --- a/test/test_param_types.py +++ b/test/test_param_types.py @@ -10,7 +10,7 @@ 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.name).is_equal_to("story") assert_that(story_type.bookshelf_storage).is_equal_to(bookshelf_storage) @@ -21,24 +21,26 @@ def test_story_type_shell_complete_filters_stories(): # 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_test_story("SampleStory1"), + create_test_story("SampleStory2"), ] # Create MagicMock objects for ctx, param, and incomplete ctx = MagicMock() param = MagicMock() - incomplete = 'Sam' + 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') + 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) + 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 011ee83..dfb27af 100644 --- a/test/test_storage.py +++ b/test/test_storage.py @@ -10,16 +10,20 @@ from bookshelf.models import Story from bookshelf.storage import BookshelfStorage from bookshelf.exceptions import StoryNotFoundException -from test.utils.storage_utils import create_test_story_dict, create_story_json_string, create_test_story +from test.utils.storage_utils import ( + create_test_story_dict, + create_story_json_string, + create_test_story, +) -BOOKSHELF_DIR = f'{Path.home()}/.bookshelf' -BOOKSHELF_TASK_DIR = f'{BOOKSHELF_DIR}/tasks' +BOOKSHELF_DIR = f"{Path.home()}/.bookshelf" +BOOKSHELF_TASK_DIR = f"{BOOKSHELF_DIR}/tasks" @pytest.fixture(autouse=True) def mock_home(): - with patch('pathlib.Path.home') as mock_home: - mock_home.return_value = 'BOOKSHELF_DIR' + with patch("pathlib.Path.home") as mock_home: + mock_home.return_value = "BOOKSHELF_DIR" yield mock_home @@ -32,8 +36,8 @@ def test_delete_existing_story(bookshelf_storage, mock_home): os.remove = MagicMock() os.path.exists = MagicMock(return_value=True) - story_name = 'my_story' - story_path = os.path.join(f'{BOOKSHELF_TASK_DIR}/{story_name}.json') + story_name = "my_story" + story_path = os.path.join(f"{BOOKSHELF_TASK_DIR}/{story_name}.json") bookshelf_storage.delete_story(story_name) @@ -43,14 +47,15 @@ def test_delete_existing_story(bookshelf_storage, mock_home): def test_delete_nonexistent_story(bookshelf_storage, mock_home): os.path.exists = MagicMock(return_value=False) - story_name = 'my_story' + story_name = "my_story" with pytest.raises(StoryNotFoundException) as exc_info: bookshelf_storage.delete_story(story_name) assert_that(exc_info.value.terminal_message).is_equal_to( - f'❌Could not find story with name [bold]{story_name}[/bold].\n' - f'💡You can create one with [bold]bookshelf create {story_name}[/bold].') + f"❌Could not find story with name [bold]{story_name}[/bold].\n" + f"💡You can create one with [bold]bookshelf create {story_name}[/bold]." + ) def test_load_existing_story(bookshelf_storage, mock_home): @@ -66,7 +71,7 @@ def test_load_existing_story(bookshelf_storage, mock_home): m = mock_open(read_data=test_story_json) # Patch the open function to use the mock - with patch('builtins.open', m): + with patch("builtins.open", m): # Call the function that reads from the file story = bookshelf_storage.load_story(story_path) @@ -76,14 +81,15 @@ def test_load_existing_story(bookshelf_storage, mock_home): def test_load_nonexistent_story(bookshelf_storage, mock_home): os.path.exists = MagicMock(return_value=False) - story_name = 'my_story' + story_name = "my_story" with pytest.raises(StoryNotFoundException) as exc_info: bookshelf_storage.load_story(story_name) assert_that(exc_info.value.terminal_message).is_equal_to( - f'❌Could not find story with name [bold]{story_name}[/bold].\n' - f'💡You can create one with [bold]bookshelf create {story_name}[/bold].') + f"❌Could not find story with name [bold]{story_name}[/bold].\n" + f"💡You can create one with [bold]bookshelf create {story_name}[/bold]." + ) def test_save_non_existing_story(bookshelf_storage, mock_home): @@ -99,34 +105,34 @@ def test_save_non_existing_story(bookshelf_storage, mock_home): m_open = mock_open() - with patch('builtins.open', m_open): + with patch("builtins.open", m_open): bookshelf_storage.save_story(test_story) os.makedirs.assert_called_once_with(BOOKSHELF_TASK_DIR) - m_open.assert_called_once_with(story_path, 'w') + m_open.assert_called_once_with(story_path, "w") json.dump.assert_called_once_with(json.loads(test_story_json_string), m_open()) def test_get_all_stories(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_1 = create_test_story(name="Test1") + test_story_2 = create_test_story(name="Test2") + test_story_3 = create_test_story(name="Test3") test_stories = [test_story_1, test_story_2, test_story_3] os.walk = MagicMock() - os.walk.return_value = [(BOOKSHELF_TASK_DIR, - [], - [f'{story.name}.json' for story in test_stories])] + 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('\'', '"') + yield str(story.to_json()).replace("'", '"') m_open = mock_open() m_open().read.side_effect = side_effect() - with patch('builtins.open', m_open): + with patch("builtins.open", m_open): stories = list(bookshelf_storage.get_all_stories()) assert_that(stories).contains_only(test_story_1, test_story_2, test_story_3) @@ -134,13 +140,11 @@ def side_effect(*args, **kwargs): def test_get_all_stories_with_invalid_json(bookshelf_storage): os.walk = MagicMock() - os.walk.return_value = [(BOOKSHELF_TASK_DIR, - [], - ['a.json'])] + os.walk.return_value = [(BOOKSHELF_TASK_DIR, [], ["a.json"])] m_open = mock_open() - m_open().read.return_value = 'invalid json' + m_open().read.return_value = "invalid json" - with patch('builtins.open', m_open): + with patch("builtins.open", m_open): stories = list(bookshelf_storage.get_all_stories()) assert_that(stories).is_empty() @@ -148,39 +152,41 @@ def test_get_all_stories_with_invalid_json(bookshelf_storage): def test_get_all_stories_with_non_json(bookshelf_storage): os.walk = MagicMock() - os.walk.return_value = [(BOOKSHELF_TASK_DIR, - [], - ['a.json'])] + os.walk.return_value = [(BOOKSHELF_TASK_DIR, [], ["a.json"])] m_open = mock_open() - m_open().read.return_value = 'invalid json' + m_open().read.return_value = "invalid json" - with patch('builtins.open', m_open): + with patch("builtins.open", m_open): 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') +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])] + 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('\'', '"') + 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')) + 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) diff --git a/test/utils/chapter_utils.py b/test/utils/chapter_utils.py index 8a7496b..9a14907 100644 --- a/test/utils/chapter_utils.py +++ b/test/utils/chapter_utils.py @@ -9,7 +9,9 @@ def create_in_progress_chapter() -> Chapter: return Chapter(start_time) -def create_chapter_from_delta(delta: timedelta, start_time: Optional[datetime] = None) -> Chapter: +def create_chapter_from_delta( + delta: timedelta, start_time: Optional[datetime] = None +) -> Chapter: if start_time is None: start_time = datetime.now() end_time = start_time + delta diff --git a/test/utils/storage_utils.py b/test/utils/storage_utils.py index 89477e4..a24ad21 100644 --- a/test/utils/storage_utils.py +++ b/test/utils/storage_utils.py @@ -5,13 +5,15 @@ from .chapter_utils import create_chapter_from_delta -def create_test_story_dict(name: Optional[str] = None, - start_date: Optional[datetime] = None, - end_date: Optional[datetime] = None, - chapters: Optional[list[Chapter]] = None, - tags: Optional[list[str]] = None) -> dict: +def create_test_story_dict( + name: Optional[str] = None, + start_date: Optional[datetime] = None, + end_date: Optional[datetime] = None, + chapters: Optional[list[Chapter]] = None, + tags: Optional[list[str]] = None, +) -> dict: if name is None: - name = 'TEST_TASK' + name = "TEST_TASK" if start_date is None: start_date = datetime.now() if end_date is None: @@ -20,38 +22,46 @@ def create_test_story_dict(name: Optional[str] = None, chapters = [ create_chapter_from_delta(timedelta(hours=1)), create_chapter_from_delta(timedelta(hours=1)), - create_chapter_from_delta(timedelta(hours=1)) + create_chapter_from_delta(timedelta(hours=1)), ] if tags is None: - tags = ['tag1', 'tag2'] + tags = ["tag1", "tag2"] return { - 'name': name, - 'start_date': start_date, - 'end_date': end_date, - 'chapters': chapters, - 'tags': tags + "name": name, + "start_date": start_date, + "end_date": end_date, + "chapters": chapters, + "tags": tags, } -def create_test_story(name: Optional[str] = None, - start_date: Optional[datetime] = None, - end_date: Optional[datetime] = None, - chapters: Optional[list[Chapter]] = None, - tags: Optional[list[str]] = None) -> Story: +def create_test_story( + name: Optional[str] = None, + start_date: Optional[datetime] = None, + end_date: Optional[datetime] = None, + chapters: Optional[list[Chapter]] = None, + tags: Optional[list[str]] = None, +) -> Story: return Story(**create_test_story_dict(name, start_date, end_date, chapters, tags)) -def create_story_json_string(name: str, start_date: datetime, end_date: Optional[datetime] = None, - chapters: Optional[list[Chapter]] = None, - tags: Optional[list[str]] = None) -> str: +def create_story_json_string( + name: str, + start_date: datetime, + end_date: Optional[datetime] = None, + chapters: Optional[list[Chapter]] = None, + tags: Optional[list[str]] = None, +) -> str: if chapters is None: chapters = [] if tags is None: tags = [] - return str({ - 'name': name, - 'start_date': start_date.isoformat(), - 'end_date': end_date.isoformat() if end_date else None, - 'tags': tags, - 'chapters': [chapter.to_json() for chapter in chapters] - }).replace('\'', '"') + return str( + { + "name": name, + "start_date": start_date.isoformat(), + "end_date": end_date.isoformat() if end_date else None, + "tags": tags, + "chapters": [chapter.to_json() for chapter in chapters], + } + ).replace("'", '"')