From b174d6b16b08153d1f6f774787a0505551274619 Mon Sep 17 00:00:00 2001 From: Eli <43382407+eli64s@users.noreply.github.com> Date: Thu, 4 Jan 2024 10:09:10 -0600 Subject: [PATCH 1/2] Refactor markdown builder to improve maintainability. --- readmeai/markdown/badges.py | 19 ++- readmeai/markdown/builder.py | 172 ++++++++++++++++++++++++++++ readmeai/markdown/headers.py | 132 --------------------- readmeai/markdown/tables.py | 13 +-- readmeai/markdown/tree.py | 65 ++++------- tests/test_markdown/test_badges.py | 23 +--- tests/test_markdown/test_builder.py | 79 +++++++++++++ tests/test_markdown/test_headers.py | 62 ---------- tests/test_markdown/test_tables.py | 3 +- tests/test_markdown/test_tree.py | 123 +++++++------------- 10 files changed, 336 insertions(+), 355 deletions(-) create mode 100644 readmeai/markdown/builder.py delete mode 100644 readmeai/markdown/headers.py create mode 100644 tests/test_markdown/test_builder.py delete mode 100644 tests/test_markdown/test_headers.py diff --git a/readmeai/markdown/badges.py b/readmeai/markdown/badges.py index 87aee7a4..cfe1e1b4 100644 --- a/readmeai/markdown/badges.py +++ b/readmeai/markdown/badges.py @@ -2,7 +2,8 @@ from typing import Dict, Tuple -from readmeai.config.settings import AppConfig, BadgeOptions +from readmeai.config.enums import BadgeOptions +from readmeai.config.settings import AppConfig from readmeai.core.factory import FileHandler from readmeai.core.utils import get_resource_path from readmeai.services.git_utilities import GitService @@ -31,13 +32,11 @@ def build_dependency_badges( return format_badges(badges) -def build_metadata_badges( - config: AppConfig, host: str, repository: str -) -> str: +def build_metadata_badges(config: AppConfig, full_name: str, host: str) -> str: """Build metadata badges using shields.io.""" return config.md.badges_shields.format( host=host, - full_name=repository, + full_name=full_name, badge_style=config.md.badge_style, badge_color=config.md.badge_color, ) @@ -71,16 +70,16 @@ def format_badges(badges: list[str]) -> str: def shields_icons( - conf: AppConfig, deps: list, full_name: str + conf: AppConfig, deps: list, full_name: str, git_host: str ) -> Tuple[str, str]: """ Generates badges for the README using shields.io icons, referencing the repository - https://github.com/Aveek-Saha/GitHub-Profile-Badges. """ badge_set = _read_badge_file(conf.files.shields_icons) - git_host = GitService.extract_name_from_host(conf.git.source) - metadata_badges = build_metadata_badges(conf, git_host, full_name) + metadata_badges = build_metadata_badges(conf, full_name, git_host) + dependency_badges = build_dependency_badges( deps, badge_set, conf.md.badge_style ) @@ -90,14 +89,14 @@ def shields_icons( if ( conf.md.badge_style == BadgeOptions.DEFAULT.value - and git_host != GitService.LOCAL.host + and git_host != GitService.LOCAL ): return ( metadata_badges, "\n", ) - if git_host == GitService.LOCAL.host: + if git_host == GitService.LOCAL: return ( "\n", dependency_badges, diff --git a/readmeai/markdown/builder.py b/readmeai/markdown/builder.py new file mode 100644 index 00000000..994dc112 --- /dev/null +++ b/readmeai/markdown/builder.py @@ -0,0 +1,172 @@ +"""Builds each section of the README Markdown file.""" + +__package__ = "readmeai" + +import re +from pathlib import Path +from typing import List + +from readmeai.config.enums import BadgeOptions +from readmeai.config.settings import AppConfig, ConfigHelper, GitService +from readmeai.core import factory +from readmeai.markdown import badges, tables, tree +from readmeai.markdown.quickstart import getting_started + + +class ReadmeBuilder: + """Builds each section of the README Markdown file.""" + + def __init__( + self, + conf: AppConfig, + helper: ConfigHelper, + dependencies: list, + summaries: tuple, + temp_dir: str, + ): + """Initializes the ReadmeBuilder class.""" + self.conf = conf + self.helper = helper + self.deps = dependencies + self.summaries = summaries + self.temp_dir = temp_dir + self.md = self.conf.md + self.host = self.conf.git.host + self.full_name = self.conf.git.full_name + self.repo_name = self.conf.git.name + self.repo_url = self.conf.git.repository + if self.host == GitService.LOCAL: + self.repo_url = f"../{self.repo_name}" + + @property + def md_header(self) -> str: + """Generates the README header section.""" + if BadgeOptions.SKILLS.value not in self.md.badge_style: + md_shields, md_badges = badges.shields_icons( + self.conf, self.deps, self.full_name, self.host + ) + else: + md_shields = ( + "" + ) + md_badges = badges.skill_icons(self.conf, self.deps) + + return self.md.header.format( + align=self.md.align, + image=self.md.image, + repo_name=self.repo_name.upper(), + slogan=self.md.slogan, + badges_shields=md_shields, + badges=md_badges, + ) + + @property + def md_summaries(self) -> str: + """Generates the README code summaries section.""" + formatted_summaries = tables.format_code_summaries( + self.md.default, + self.summaries, + ) + md_summaries = tables.generate_markdown_tables( + self.md.modules_widget, + formatted_summaries, + self.full_name, + self.repo_url, + ) + return md_summaries + + @property + def md_tree(self) -> str: + """Generates the README directory tree structure.""" + tree_generator = tree.TreeGenerator( + conf_helper=self.helper, + repo_name=self.repo_name, + repo_url=self.repo_url, + root_dir=self.temp_dir, + ) + tree_structure = tree_generator.run() + return self.md.tree.format(tree_structure) + + @property + def md_quickstart(self) -> str: + """Generates the README Getting Started section.""" + project_setup = getting_started(self.conf, self.helper, self.summaries) + md_project_setup = self.md.getting_started.format( + repo_name=self.repo_name, + repo_url=self.repo_url, + language_name=project_setup.top_language_full_name, + install_command=project_setup.install_command, + run_command=project_setup.run_command, + test_command=project_setup.test_command, + ) + return md_project_setup + + def build(self) -> str: + """Builds the README Markdown file.""" + readme_md_sections = [ + self.md_header, + self.md.toc.format(self.repo_name), + self.md.overview, + self.md.features, + self.md_tree, + self.md.modules, + self.md_summaries, + self.md_quickstart, + self.md.contribute.format( + full_name=self.full_name, repo_name=self.repo_name + ), + ] + + if self.conf.cli.emojis is False: + readme_md_sections = self.remove_emojis(readme_md_sections) + + return "\n".join(readme_md_sections) + + @staticmethod + def remove_emojis(content_list: List[str]) -> List[str]: + """Removes emojis from headers and ToC.""" + emoji_pattern = re.compile( + pattern="[" + "\U0001F600-\U0001F64F" # emoticons + "\U0001F300-\U0001F5FF" # symbols & pictographs + "\U0001F680-\U0001F6FF" # transport & map symbols + "\U0001F700-\U0001F77F" # alchemical symbols + "\U0001F780-\U0001F7FF" # Geometric Shapes Extended + "\U0001F800-\U0001F8FF" # Supplemental Arrows-C + "\U0001F900-\U0001F9FF" # Supplemental Symbols and Pictographs + "\U0001FA00-\U0001FA6F" # Chess Symbols + "\U0001FA70-\U0001FAFF" # Symbols and Pictographs Extended-A + "\U00002702-\U000027B0" # Dingbats + "\U000024C2-\U0001F251" # flags (iOS) + "]+", + flags=re.UNICODE, + ) + modified_content = [] + + for section in content_list: + lines = section.split("\n") + for index, line in enumerate(lines): + if ( + line.startswith("#") + or "Table of Contents" in section + or "Quick Links" in section + ): + lines[index] = emoji_pattern.sub("", line) + modified_content.append("\n".join(lines)) + + return modified_content + + +def build_readme_md( + conf: AppConfig, + helper: ConfigHelper, + dependencies: list, + summaries: tuple, + temp_dir: str, +) -> None: + """Builds the README Markdown file.""" + builder = ReadmeBuilder(conf, helper, dependencies, summaries, temp_dir) + readme_md_file = builder.build() + readme_path = Path(conf.files.output) + readme_path.parent.mkdir(parents=True, exist_ok=True) + factory.FileHandler().write(readme_path, readme_md_file) diff --git a/readmeai/markdown/headers.py b/readmeai/markdown/headers.py deleted file mode 100644 index 590ac1bd..00000000 --- a/readmeai/markdown/headers.py +++ /dev/null @@ -1,132 +0,0 @@ -"""Builds each section of the README Markdown file.""" - -__package__ = "readmeai" - -import re -from pathlib import Path -from typing import List - -from readmeai.config.settings import ( - AppConfig, - BadgeOptions, - ConfigHelper, - GitService, -) -from readmeai.core import factory -from readmeai.markdown import badges, tables -from readmeai.markdown.quickstart import getting_started -from readmeai.services.git_utilities import get_remote_full_name - - -def build_readme_md( - conf: AppConfig, - helper: ConfigHelper, - deps: list, - summaries: tuple, -) -> None: - """Constructs each section of the README Markdown file.""" - readme_md_contents = format_readme_md(conf, helper, deps, summaries) - readme_md_file = "\n".join(readme_md_contents) - readme_path = Path(conf.files.output) - readme_path.parent.mkdir(parents=True, exist_ok=True) - factory.FileHandler().write(readme_path, readme_md_file) - - -def format_readme_md( - conf: AppConfig, - helper: ConfigHelper, - deps: list, - summaries: tuple, -) -> List[str]: - """Formats the README markdown contents.""" - repo_url = conf.git.repository - user_name, repo_name = get_remote_full_name(repo_url) - full_name = f"{user_name}/{repo_name}" - if conf.git.source == GitService.LOCAL.value: - repo_url = f"../{repo_name}" - - if BadgeOptions.SKILLS.value not in conf.md.badge_style: - md_shields, md_badges = badges.shields_icons(conf, deps, full_name) - else: - md_shields = "" - md_badges = badges.skill_icons(conf, deps) - - md_header = conf.md.header.format( - align=conf.md.align, - image=conf.md.image, - repo_name=repo_name.upper(), - slogan=conf.md.slogan, - badges_shields=md_shields, - badges=md_badges, - ) - - formatted_code_summaries = tables.format_code_summaries( - conf.md.default, - summaries, - ) - md_code_summaries = tables.generate_markdown_tables( - conf.md.modules_widget, formatted_code_summaries, full_name, repo_url - ) - - project_setup = getting_started(conf, helper, summaries) - md_project_setup = conf.md.getting_started.format( - repo_name=repo_name, - repo_url=repo_url, - language_name=project_setup.top_language_full_name, - install_command=project_setup.install_command, - run_command=project_setup.run_command, - test_command=project_setup.test_command, - ) - - readme_md_sections = [ - md_header, - conf.md.toc.format(repo_name), - conf.md.overview, - conf.md.features, - conf.md.tree, - conf.md.modules, - md_code_summaries, - md_project_setup, - conf.md.contribute.format(full_name=full_name, repo_name=repo_name), - ] - - if conf.cli.emojis is False: - return remove_emojis_from_headers(readme_md_sections) - - return readme_md_sections - - -def remove_emojis_from_headers(content_list: List[str]) -> List[str]: - """Removes emojis from headers and ToC.""" - emoji_pattern = re.compile( - pattern="[" - "\U0001F600-\U0001F64F" # emoticons - "\U0001F300-\U0001F5FF" # symbols & pictographs - "\U0001F680-\U0001F6FF" # transport & map symbols - "\U0001F700-\U0001F77F" # alchemical symbols - "\U0001F780-\U0001F7FF" # Geometric Shapes Extended - "\U0001F800-\U0001F8FF" # Supplemental Arrows-C - "\U0001F900-\U0001F9FF" # Supplemental Symbols and Pictographs - "\U0001FA00-\U0001FA6F" # Chess Symbols - "\U0001FA70-\U0001FAFF" # Symbols and Pictographs Extended-A - "\U00002702-\U000027B0" # Dingbats - "\U000024C2-\U0001F251" # flags (iOS) - "]+", - flags=re.UNICODE, - ) - - # Replace emojis for markdown lines starting with header symbols or ToC - modified_content = [] - - for section in content_list: - lines = section.split("\n") - for index, line in enumerate(lines): - if ( - line.startswith("#") - or "Table of Contents" in section - or "Quick Links" in section - ): - lines[index] = emoji_pattern.sub("", line) - modified_content.append("\n".join(lines)) - - return modified_content diff --git a/readmeai/markdown/tables.py b/readmeai/markdown/tables.py index 6fd9d540..7bc47ada 100644 --- a/readmeai/markdown/tables.py +++ b/readmeai/markdown/tables.py @@ -10,16 +10,14 @@ def construct_markdown_table( - data: List[Tuple[str, str]], repository: str, project_name: str + data: List[Tuple[str, str]], repo_url: str, full_name: str ) -> str: """Builds a Markdown table from the provided data.""" headers = ["File", "Summary"] table_rows = [headers, ["---", "---"]] for module, summary in data: file_name = str(Path(module).name) - hyperlink = create_hyperlink( - file_name, project_name, module, repository - ) + hyperlink = create_hyperlink(file_name, full_name, module, repo_url) table_rows.append([hyperlink, summary]) return format_as_markdown_table(table_rows) @@ -29,13 +27,6 @@ def create_hyperlink( ) -> str: """ Creates a hyperlink for a file, using its Git URL if possible. - logger.debug( - f"Creating git host file hyperlink:\n\ - \tFile: {file_name} \n\ - \tFull name: {full_name} \n\ - \tModule: {module} \n\ - \tRepo URL: {repo_url}" - ) """ if "invalid" in full_name.lower(): return file_name diff --git a/readmeai/markdown/tree.py b/readmeai/markdown/tree.py index d50e2edb..746aacc0 100644 --- a/readmeai/markdown/tree.py +++ b/readmeai/markdown/tree.py @@ -15,70 +15,55 @@ class TreeGenerator: def __init__( self, conf_helper: ConfigHelper, - root_dir: Path, - repo_url: Path, repo_name: str, + repo_url: Path, + root_dir: Path, max_depth: int = 3, ): self.config_helper = conf_helper - self.root_dir = Path(root_dir) self.repo_name = repo_name self.repo_url = repo_url + self.root_dir = Path(root_dir) self.max_depth = max_depth def run(self) -> str: """Generates and formats a tree structure.""" tree_str = self._generate_tree(self.root_dir) - formatted_tree_str = self._format_tree(tree_str) - return formatted_tree_str + return tree_str.replace(self.root_dir.name, f"{self.repo_name}/") def _generate_tree( self, directory: Path, prefix: str = "", is_last: bool = True, - parent_prefix: str = "", depth: int = 0, ) -> str: """Generates a tree structure for a given directory.""" - if depth > self.max_depth: + if depth > self.max_depth or utils.should_ignore( + self.config_helper, directory + ): return "" - if utils.should_ignore(self.config_helper, directory): - return "" + children = sorted(directory.iterdir()) if directory.is_dir() else [] + children = [ + child + for child in children + if not utils.should_ignore(self.config_helper, child) + ] - display_name = "." if directory == self.repo_url else directory.name - box_branch = "└── " if is_last else "├── " - parts = [parent_prefix + box_branch + display_name] + # If the directory is empty or contains only ignored files, return an empty string + if not children and directory.is_dir(): + return "" - if directory.is_dir(): - parts.append("/\n") - children = sorted( - [ - child - for child in directory.iterdir() - if child.name != ".git" - ] + parts = [f"{prefix}{'└── ' if is_last else '├── '}{directory.name}"] + for index, child in enumerate(children): + child_prefix = prefix + (" " if is_last else "│ ") + child_tree = self._generate_tree( + child, child_prefix, index == len(children) - 1, depth + 1 ) - for index, child in enumerate(children): - is_last_child = index == len(children) - 1 - child_prefix = " " if is_last else "│ " - parts.append( - self._generate_tree( - child, - box_branch, - is_last_child, - f"{parent_prefix}{child_prefix}", - depth + 1, - ) - ) - else: - parts.append("\n") - return "".join(parts) + # Append the child tree only if it's not empty + if child_tree: + parts.append(child_tree) - def _format_tree(self, tree_str: str) -> str: - """Formats the directory tree structure.""" - tree_str = tree_str.split("\n", 1) - tree_str[0] = f"└── {self.repo_name}/" - return "\n".join(tree_str) + return "\n".join(parts) diff --git a/tests/test_markdown/test_badges.py b/tests/test_markdown/test_badges.py index f301b38c..c4dba38b 100644 --- a/tests/test_markdown/test_badges.py +++ b/tests/test_markdown/test_badges.py @@ -88,17 +88,9 @@ def test_build_metadata_badges_success(config): assert "license" in badges -def test_shields_icons_success(config): +def test_shields_icons_success(config, mock_dependencies): """Tests shields_icons with valid inputs.""" - mock_config = config - mock_config.git.source = "github.com" - mock_config.files.shields_icons = config.files.shields_icons - mock_config.md.badge_style = "flat" - - mock_helper = MagicMock() - mock_helper.language_setup = {"Python": ["install", "run", "test"]} - - deps = ["Python", "JavaScript"] + config.md.badge_style = "flat" with patch("readmeai.markdown.badges._read_badge_file") as mock_read: mock_read.return_value = { @@ -108,19 +100,16 @@ def test_shields_icons_success(config): ], } shields_badges, deps_badges = shields_icons( - mock_config, deps, "full_name" + config, mock_dependencies, "github.com", "github" ) assert "license" in shields_badges assert "Python" in deps_badges - assert "3776AB" in deps_badges - assert "flat" in deps_badges + assert "shields.io" in deps_badges def test_skill_icons_success(config): """Tests skill_icons with valid inputs.""" - mock_config = config - mock_config.files.skill_icons = config.files.skill_icons - mock_config.md.badge_style = "skills-light" + config.md.badge_style = "skills-light" mock_icons = { "icons": {"names": ["fastapi", "py", "redis", "md", "github", "git"]}, "url": {"base_url": "https://skillicons.dev/icons?i="}, @@ -129,7 +118,7 @@ def test_skill_icons_success(config): with patch("readmeai.markdown.badges._read_badge_file") as mock_read: mock_read.return_value = mock_icons - result = skill_icons(mock_config, deps) + result = skill_icons(config, deps) assert "&theme=light" in result assert """""" in result assert ( diff --git a/tests/test_markdown/test_builder.py b/tests/test_markdown/test_builder.py new file mode 100644 index 00000000..a20009a4 --- /dev/null +++ b/tests/test_markdown/test_builder.py @@ -0,0 +1,79 @@ +"""Unit tests for the README Markdown file builder.""" + +from unittest.mock import patch + +import pytest + +from readmeai.markdown.builder import ReadmeBuilder, build_readme_md + + +@pytest.fixture +def readme_builder( + config, config_helper, mock_dependencies, mock_summaries, mock_temp_dir +): + return ReadmeBuilder( + config, config_helper, mock_dependencies, mock_summaries, mock_temp_dir + ) + + +def test_md_header(readme_builder): + """Tests if md_header property returns a string.""" + header = readme_builder.md_header + assert isinstance(header, str) + + +def test_md_summaries(readme_builder): + """Tests if md_summaries property returns a string.""" + summaries = readme_builder.md_summaries + assert isinstance(summaries, str) + + +def test_md_tree(readme_builder): + """Tests if md_tree property returns a string.""" + tree = readme_builder.md_tree + assert isinstance(tree, str) + + +def test_md_quickstart(readme_builder): + """Tests if md_quickstart property returns a string.""" + quickstart = readme_builder.md_quickstart + assert isinstance(quickstart, str) + + +@patch("readmeai.markdown.builder.ReadmeBuilder.remove_emojis") +def test_build_with_emojis(mock_remove_emojis, config, readme_builder): + """Tests if emojis are removed when the config is set.""" + config.cli.emojis = False + readme_builder.build() + mock_remove_emojis.assert_called_once() + + +@patch("readmeai.markdown.builder.factory.FileHandler.write") +def test_build_readme_md( + mock_write, + config, + config_helper, + mock_dependencies, + mock_summaries, + mock_temp_dir, +): + """Tests the build_readme_md function.""" + build_readme_md( + config, config_helper, mock_dependencies, mock_summaries, mock_temp_dir + ) + mock_write.assert_called_once() + + +def test_remove_emojis_from_headers_with_emojis(): + """Tests the remove_emojis static method with emojis.""" + content_with_emojis = ["# Header 🚀", "## Another Header 😃"] + expected_output = ["# Header ", "## Another Header "] + result = ReadmeBuilder.remove_emojis(content_with_emojis) + assert result == expected_output + + +def test_remove_emojis_from_headers_without_emojis(): + """Tests the remove_emojis static method without emojis.""" + content_without_emojis = ["# Header", "## Another Header"] + result = ReadmeBuilder.remove_emojis(content_without_emojis) + assert result == content_without_emojis diff --git a/tests/test_markdown/test_headers.py b/tests/test_markdown/test_headers.py deleted file mode 100644 index 2361d880..00000000 --- a/tests/test_markdown/test_headers.py +++ /dev/null @@ -1,62 +0,0 @@ -"""Unit tests for the markdown headers generator.""" - -from unittest.mock import patch - -from readmeai.markdown.headers import ( - build_readme_md, - format_readme_md, - remove_emojis_from_headers, -) - - -def test_build_readme_md( - config, config_helper, mock_dependencies, mock_summaries -): - """Tests the build_readme_md method.""" - with patch("readmeai.core.factory.FileHandler.write") as mock_write: - build_readme_md( - config, config_helper, mock_dependencies, mock_summaries - ) - mock_write.assert_called_once() - written_content = mock_write.call_args[0][1] - assert isinstance(written_content, str) - assert len(written_content) > 0 - - -def test_format_readme_md_local_source( - config, config_helper, mock_dependencies, mock_summaries -): - """Tests the format_readme_md method.""" - readme_md = format_readme_md( - config, config_helper, mock_dependencies, mock_summaries - ) - assert isinstance(readme_md, list) - assert len(readme_md) == 9 - - -def test_format_readme_md_nonlocal_source( - config, config_helper, mock_dependencies, mock_summaries -): - """Tests the format_readme_md method.""" - mock_config = config - mock_config.cli.offline = True - readme_md = format_readme_md( - mock_config, config_helper, mock_dependencies, mock_summaries - ) - assert isinstance(readme_md, list) - - -def test_remove_emojis_from_headers_with_emojis(): - """Tests the remove_emojis_from_headers method.""" - content_with_emojis = ["# Header 🚀", "## Another Header 😃"] - expected_output = ["# Header ", "## Another Header "] - assert remove_emojis_from_headers(content_with_emojis) == expected_output - - -def test_remove_emojis_from_headers_without_emojis(): - """Tests the remove_emojis_from_headers method.""" - content_without_emojis = ["# Header", "## Another Header"] - assert ( - remove_emojis_from_headers(content_without_emojis) - == content_without_emojis - ) diff --git a/tests/test_markdown/test_tables.py b/tests/test_markdown/test_tables.py index 20176196..bb10f6f9 100644 --- a/tests/test_markdown/test_tables.py +++ b/tests/test_markdown/test_tables.py @@ -30,9 +30,8 @@ def test_create_hyperlink(config): """Test that the create_hyperlink function creates the hyperlink.""" file_name = "main.py" module = f"readmeai/{file_name}" + full_name = config.git.full_name repo_url = config.git.repository - user, project = git_utilities.get_remote_full_name(repo_url) - full_name = f"{user}/{project}" repo_file_url = git_utilities.get_remote_file_url( module, full_name, repo_url ) diff --git a/tests/test_markdown/test_tree.py b/tests/test_markdown/test_tree.py index 2a35b6d6..561464b9 100644 --- a/tests/test_markdown/test_tree.py +++ b/tests/test_markdown/test_tree.py @@ -1,108 +1,69 @@ """Unit tests for the markdown tree generator.""" -from pathlib import Path -from unittest.mock import Mock - import pytest -from readmeai.config.settings import ConfigHelper from readmeai.markdown.tree import TreeGenerator @pytest.fixture -def mock_config_helper(): - """Generates a mock ConfigHelper object.""" - mock_config_helper = Mock(ConfigHelper) - mock_config_helper.ignore_files = { - "files": ["*.pyc", "*.pyo"], - "extensions": ["pyc", "pyo"], - "directories": ["__pycache__", ".git"], - } - return mock_config_helper - - -@pytest.fixture -def tree_gen(mock_config_helper, tmp_path): - """Generates a tree structure for a given directory.""" - (tmp_path / "dir1").mkdir() - (tmp_path / "dir1" / "file1.txt").touch() +def tree_gen(config_helper, tmp_path): + """Fixture for the TreeGenerator class.""" + dir1 = tmp_path / "dir1" + dir1.mkdir() + (dir1 / "file1.txt").touch() (tmp_path / "dir2").mkdir() - tree_gen = TreeGenerator( - conf_helper=mock_config_helper, - root_dir=tmp_path, - repo_url="http://repo.url", + return TreeGenerator( + conf_helper=config_helper, repo_name="TestProject", + repo_url=tmp_path, + root_dir=tmp_path, max_depth=3, ) - return tree_gen -def test_initialization(mock_config_helper): - """Tests the initialization of the TreeGenerator class.""" - tree_gen = TreeGenerator( - conf_helper=mock_config_helper, - root_dir=Path("/test/path"), - repo_url="http://repo.url", - repo_name="TestProject", - ) - assert tree_gen.root_dir == Path("/test/path") - assert tree_gen.repo_url == "http://repo.url" +def test_initialization(tree_gen): + """Test initialization of the TreeGenerator class.""" + assert tree_gen.root_dir.is_dir() assert tree_gen.repo_name == "TestProject" assert tree_gen.max_depth == 3 def test_generate_tree(tree_gen, tmp_path): - """Tests the _generate_tree method.""" + """Test the _generate_tree method.""" tree = tree_gen._generate_tree(tmp_path) - expected_tree = """└── test_generate_tree0/ - ├── dir1/ - │ └── file1.txt - └── dir2/\n""" + expected_tree = ( + f"└── {tmp_path.name}\n" " ├── dir1\n" " │ └── file1.txt" + ) assert tree == expected_tree -def test_format_tree(tree_gen, tmp_path): - """Tests the _format_tree method.""" - tree = tree_gen._generate_tree(tmp_path) - formatted_tree = tree_gen._format_tree(tree) - expected_formatted_tree = """└── TestProject/ - ├── dir1/ - │ └── file1.txt - └── dir2/\n""" - assert formatted_tree == expected_formatted_tree - - @pytest.mark.parametrize( - "depth, expected", + "depth, expected_suffix", [ - ( - 2, - """└── TestProject/ - └── dir1/ - └── dir2/\n""", - ), - ( - 5, - """└── TestProject/ - └── dir1/ - └── dir2/ - └── dir3/ - └── dir4/\n""", - ), + (0, ""), + (1, "\n ├── dir1"), ], ) -def test_max_depth(depth, expected, mock_config_helper, tmp_path): - """Tests the max_depth parameter of the TreeGenerator class.""" - (tmp_path / "dir1").mkdir() - (tmp_path / "dir1" / "dir2").mkdir() - (tmp_path / "dir1" / "dir2" / "dir3").mkdir() - (tmp_path / "dir1" / "dir2" / "dir3" / "dir4").mkdir() - tree_gen = TreeGenerator( - conf_helper=mock_config_helper, - root_dir=tmp_path, - repo_url="http://repo.url", - repo_name="TestProject", - max_depth=depth, - ) - tree = tree_gen.run() - assert tree == expected +def test_max_depth_param(tree_gen, depth, expected_suffix, tmp_path): + """Test the _generate_tree method.""" + tree_gen.max_depth = depth + tree = tree_gen._generate_tree(tmp_path) + expected_tree = f"└── {tmp_path.name}{expected_suffix}" + assert tree == expected_tree + + +def test_run_method(tree_gen): + """Test the run method.""" + expected_tree = tree_gen.run() + assert "TestProject" in expected_tree + assert "dir1" in expected_tree + assert "file1.txt" in expected_tree + + +def test_ignore_files(tree_gen, tmp_path): + """Test that the tree generator ignores files.""" + (tmp_path / "dir1" / "__pycache__").mkdir() + (tmp_path / "dir1" / "file.pyc").touch() + tree = tree_gen._generate_tree(tmp_path) + assert "__pycache__" not in tree + assert "file.pyc" not in tree From 2e5c26c4dcbdf6a1c2787a2ec58bbc0ce6aa06ac Mon Sep 17 00:00:00 2001 From: Eli <43382407+eli64s@users.noreply.github.com> Date: Thu, 4 Jan 2024 10:13:45 -0600 Subject: [PATCH 2/2] Refactor settings and validations into separate files. --- pyproject.toml | 4 +- readmeai/cli/options.py | 2 +- readmeai/config/enums.py | 62 ++++++ readmeai/config/settings.py | 224 +++++++++++----------- readmeai/core/preprocess.py | 37 ++-- readmeai/main.py | 39 ++-- readmeai/services/git_utilities.py | 31 +-- readmeai/settings/config.toml | 30 ++- tests/conftest.py | 6 + tests/test_config/test_enums.py | 61 ++++++ tests/test_config/test_settings.py | 14 +- tests/test_services/test_git_utilities.py | 12 +- 12 files changed, 289 insertions(+), 233 deletions(-) create mode 100644 readmeai/config/enums.py create mode 100644 tests/test_config/test_enums.py diff --git a/pyproject.toml b/pyproject.toml index 4b86ab10..b815940f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "poetry.core.masonry.api" [tool.poetry] name = "readmeai" -version = "0.4.0987" +version = "0.4.0988" description = "🚀 Generate beautiful README.md files from the terminal using GPT LLM APIs 💫" authors = ["Eli <0x.eli.64s@gmail.com>"] license = "MIT" @@ -16,11 +16,11 @@ keywords = [ "markdown", "readme", "ai", + "generator", "devtools", "documentation", "documentation-generator", "readme-md", - "automated-documentation", "readme-generator", "openai-api", "automated-readme", diff --git a/readmeai/cli/options.py b/readmeai/cli/options.py index 02182012..1b764545 100644 --- a/readmeai/cli/options.py +++ b/readmeai/cli/options.py @@ -8,7 +8,7 @@ import click from click import Context, Parameter -from readmeai.config.settings import BadgeOptions, ImageOptions +from readmeai.config.enums import BadgeOptions, ImageOptions def prompt_for_custom_image( diff --git a/readmeai/config/enums.py b/readmeai/config/enums.py new file mode 100644 index 00000000..b2bd544f --- /dev/null +++ b/readmeai/config/enums.py @@ -0,0 +1,62 @@ +"""Enum classes for the readmeai package.""" + +from enum import Enum + + +class GitService(str, Enum): + """ + Enum class for Git service details. + """ + + LOCAL = "local" + GITHUB = "github.com" + GITLAB = "gitlab.com" + BITBUCKET = "bitbucket.org" + + @property + def api_url(self): + """Gets the API URL for the Git service.""" + return { + GitService.LOCAL: None, + GitService.GITHUB: "https://api.github.com/repos/", + GitService.GITLAB: "https://api.gitlab.com/v4/projects/", + GitService.BITBUCKET: "https://api.bitbucket.org/2.0/repositories/", + }.get(self, None) + + @property + def file_url_template(self): + """Gets the file URL template for the Git service.""" + return { + GitService.LOCAL: "{file_path}", + GitService.GITHUB: "https://github.com/{full_name}/blob/main/{file_path}", + GitService.GITLAB: "https://gitlab.com/{full_name}/-/blob/master/{file_path}", + GitService.BITBUCKET: "https://bitbucket.org/{full_name}/src/master/{file_path}", + }.get(self, None) + + +class BadgeOptions(str, Enum): + """ + Enum for CLI options for README file badge icons. + """ + + DEFAULT = "default" + FLAT = "flat" + FLAT_SQUARE = "flat-square" + FOR_THE_BADGE = "for-the-badge" + PLASTIC = "plastic" + SKILLS = "skills" + SKILLS_LIGHT = "skills-light" + SOCIAL = "social" + + +class ImageOptions(str, Enum): + """ + Enum for CLI options for README file header images. + """ + + CUSTOM = "CUSTOM" + DEFAULT = "https://raw.githubusercontent.com/PKief/vscode-material-icon-theme/ec559a9f6bfd399b82bb44393651661b08aaf7ba/icons/folder-markdown-open.svg" + BLACK = "https://img.icons8.com/external-tal-revivo-regular-tal-revivo/96/external-readme-is-a-easy-to-build-a-developer-hub-that-adapts-to-the-user-logo-regular-tal-revivo.png" + GREY = "https://img.icons8.com/external-tal-revivo-filled-tal-revivo/96/external-markdown-a-lightweight-markup-language-with-plain-text-formatting-syntax-logo-filled-tal-revivo.png" + PURPLE = "https://img.icons8.com/external-tal-revivo-duo-tal-revivo/100/external-markdown-a-lightweight-markup-language-with-plain-text-formatting-syntax-logo-duo-tal-revivo.png" + YELLOW = "https://img.icons8.com/pulsar-color/96/markdown.png" diff --git a/readmeai/config/settings.py b/readmeai/config/settings.py index 584235a5..121af3f4 100644 --- a/readmeai/config/settings.py +++ b/readmeai/config/settings.py @@ -1,7 +1,6 @@ """Data models and functions for configuring the readme-ai CLI tool.""" -import os -from enum import Enum +import re from importlib import resources from pathlib import Path from typing import Dict, List, Optional, Union @@ -10,107 +9,17 @@ import pkg_resources from pydantic import BaseModel, validator +from readmeai.config.enums import GitService from readmeai.core.factory import FileHandler from readmeai.core.logger import Logger logger = Logger(__name__) -class GitService(str, Enum): - """ - Enum class for Git service details. - """ +class GitSettingsValidator: + """Validator class for GitSettings.""" - LOCAL = ("local", None, "{file_path}") - GITHUB = ( - "github.com", - "https://api.github.com/repos/", - "https://github.com/{full_name}/blob/main/{file_path}", - ) - GITLAB = ( - "gitlab.com", - "https://api.gitlab.com/v4/projects/", - "https://gitlab.com/{full_name}/-/blob/master/{file_path}", - ) - BITBUCKET = ( - "bitbucket.org", - "https://api.bitbucket.org/2.0/repositories/", - "https://bitbucket.org/{full_name}/src/master/{file_path}", - ) - - def __new__(cls, host, api_url, file_url) -> object: - """Create a new instance of the GitService enum.""" - obj = str.__new__(cls, host) - obj._value_ = host - obj.host = host - obj.api_url = api_url - obj.file_url = file_url - return obj - - def extract_name_from_host(repo_host: str) -> str: - """Return the hostname without periods.""" - if repo_host == GitService.LOCAL.host: - return GitService.LOCAL.host - else: - return repo_host.split(".")[0] - - -class BadgeOptions(str, Enum): - """ - Enum for CLI options for README file badge icons. - """ - - DEFAULT = "default" - FLAT = "flat" - FLAT_SQUARE = "flat-square" - FOR_THE_BADGE = "for-the-badge" - PLASTIC = "plastic" - SKILLS = "skills" - SKILLS_LIGHT = "skills-light" - SOCIAL = "social" - - -class ImageOptions(str, Enum): - """ - Enum for CLI options for README file header images. - """ - - CUSTOM = "CUSTOM" - DEFAULT = "https://raw.githubusercontent.com/PKief/vscode-material-icon-theme/ec559a9f6bfd399b82bb44393651661b08aaf7ba/icons/folder-markdown-open.svg" - BLACK = "https://img.icons8.com/external-tal-revivo-regular-tal-revivo/96/external-readme-is-a-easy-to-build-a-developer-hub-that-adapts-to-the-user-logo-regular-tal-revivo.png" - GREY = "https://img.icons8.com/external-tal-revivo-filled-tal-revivo/96/external-markdown-a-lightweight-markup-language-with-plain-text-formatting-syntax-logo-filled-tal-revivo.png" - PURPLE = "https://img.icons8.com/external-tal-revivo-duo-tal-revivo/100/external-markdown-a-lightweight-markup-language-with-plain-text-formatting-syntax-logo-duo-tal-revivo.png" - YELLOW = "https://img.icons8.com/pulsar-color/96/markdown.png" - - -class CliSettings(BaseModel): - """CLI options for the readme-ai application.""" - - emojis: bool = True - offline: bool = False - - -class FileSettings(BaseModel): - """Pydantic model for configuration file paths.""" - - dependency_files: str - identifiers: str - ignore_files: str - language_names: str - language_setup: str - output: str - shields_icons: str - skill_icons: str - - -class GitSettings(BaseModel): - """Codebase repository settings and validations.""" - - repository: Union[str, Path] - source: Optional[str] - name: Optional[str] - - @validator("repository", pre=True, always=True) + @classmethod def validate_repository(cls, value: Union[str, Path]) -> Union[str, Path]: """Validate the repository URL or path.""" if isinstance(value, str): @@ -120,7 +29,7 @@ def validate_repository(cls, value: Union[str, Path]) -> Union[str, Path]: try: parsed_url = urlparse(value) if parsed_url.scheme in ["http", "https"] and any( - service.host in parsed_url.netloc for service in GitService + service in parsed_url.netloc for service in GitService ): return value except ValueError: @@ -129,22 +38,23 @@ def validate_repository(cls, value: Union[str, Path]) -> Union[str, Path]: return value raise ValueError(f"Invalid repository URL or path: {value}") - @validator("source", pre=True, always=True) - def set_source(cls, value: Optional[str], values: dict) -> str: - """Sets the Git service source from the repository provided.""" + @classmethod + def set_host(cls, value: Optional[str], values: dict) -> str: + """Sets the Git service host from the repository provided.""" repo = values.get("repository") if isinstance(repo, Path) or ( isinstance(repo, str) and Path(repo).is_dir() ): - return GitService.LOCAL.host + return GitService.LOCAL parsed_url = urlparse(str(repo)) for service in GitService: - if service.host in parsed_url.netloc: - return service.host - return GitService.LOCAL.host + if service in parsed_url.netloc: + return service.split(".")[0] + + return GitService.LOCAL - @validator("name", pre=True, always=True) + @classmethod def set_name(cls, value: Optional[str], values: dict) -> str: """Sets the repository name from the repository provided.""" repo = values.get("repository") @@ -156,6 +66,91 @@ def set_name(cls, value: Optional[str], values: dict) -> str: return name.removesuffix(".git") return "n/a" + @classmethod + def set_source(cls, value: Optional[str], values: dict) -> str: + repo = values.get("repository") + if isinstance(repo, Path) or ( + isinstance(repo, str) and Path(repo).is_dir() + ): + return GitService.LOCAL + + parsed_url = urlparse(str(repo)) + for service in GitService: + if service in parsed_url.netloc: + return service + return GitService.LOCAL + + @classmethod + def validate_full_name(cls, value: Optional[str], values: dict) -> str: + """Validator for getting the full name of the repository.""" + url_or_path = values.get("repository") + + path = ( + url_or_path if isinstance(url_or_path, Path) else Path(url_or_path) + ) + if path.exists(): + return str(path.name) + + patterns = { + GitService.GITHUB: r"https?://github.com/([^/]+)/([^/]+)", + GitService.GITLAB: r"https?://gitlab.com/([^/]+)/([^/]+)", + GitService.BITBUCKET: r"https?://bitbucket.org/([^/]+)/([^/]+)", + } + + for _, pattern in patterns.items(): + match = re.match(pattern, url_or_path) + if match: + user_name, repo_name = match.groups() + return f"{user_name}/{repo_name}" + + raise ValueError("Error: invalid repository URL or path.") + + +class CliSettings(BaseModel): + """CLI options for the readme-ai application.""" + + emojis: bool + offline: bool + + +class FileSettings(BaseModel): + """File paths for the readme-ai application.""" + + dependency_files: str + identifiers: str + ignore_files: str + language_names: str + language_setup: str + output: str + shields_icons: str + skill_icons: str + + +class GitSettings(BaseModel): + """Codebase repository settings and validations.""" + + repository: Union[str, Path] + full_name: Optional[str] + host: Optional[str] + name: Optional[str] + source: Optional[str] + + _validate_repository = validator("repository", pre=True, always=True)( + GitSettingsValidator.validate_repository + ) + _validate_full_name = validator("full_name", pre=True, always=True)( + GitSettingsValidator.validate_full_name + ) + _set_host = validator("host", pre=True, always=True)( + GitSettingsValidator.set_host + ) + _set_name = validator("name", pre=True, always=True)( + GitSettingsValidator.set_name + ) + _set_source = validator("source", pre=True, always=True)( + GitSettingsValidator.set_source + ) + class LlmApiSettings(BaseModel): """Pydantic model for OpenAI LLM API details.""" @@ -270,21 +265,28 @@ def _get_config_dict(handler: FileHandler, file_path: str) -> dict: """Get configuration dictionary from TOML file.""" try: resource_path = resources.files("readmeai.settings") / file_path - logger.info(f"Resource path using importlib: {resource_path}") + logger.info(f"Using importlib.resources to load: {resource_path}") + except TypeError as exc_info: - logger.debug(f"Error with importlib.resources: {exc_info}") + logger.debug(f"Error using importlib.resources: {exc_info}") + try: resource_path = Path( pkg_resources.resource_filename( "readmeai", f"settings/{file_path}" ) ).resolve() - logger.info(f"Resource path using pkg_resources: {resource_path}") + logger.info( + f"Using pkg_resources.resource_filename: {resource_path}" + ) + except FileNotFoundError as exc_info: - logger.debug(f"Error with pkg_resources: {exc_info}") - raise + logger.debug( + f"Error using pkg_resources.resource_filename: {exc_info}" + ) + raise FileNotFoundError(f"Config file not found: {file_path}") - if not os.path.exists(resource_path): + if not resource_path.exists(): raise FileNotFoundError(f"Config file not found: {resource_path}") return handler.read(resource_path) diff --git a/readmeai/core/preprocess.py b/readmeai/core/preprocess.py index 66d515e9..cdac2f24 100644 --- a/readmeai/core/preprocess.py +++ b/readmeai/core/preprocess.py @@ -3,7 +3,7 @@ from pathlib import Path from typing import Dict, Generator, List, Tuple -from readmeai.config import settings +from readmeai.config import enums, settings from readmeai.core.logger import Logger from readmeai.core.tokens import get_token_count from readmeai.core.utils import flatten_list, should_ignore @@ -27,14 +27,13 @@ def __init__( self.language_names = conf_helper.language_names self.language_setup = conf_helper.language_setup self.encoding_name = config.llm.encoding - self.local_source = settings.GitService.LOCAL.value def analyze(self, temp_dir: str) -> List[Dict]: """Analyzes a local or remote git repository.""" contents = self.generate_contents(temp_dir) repo_source = self.config.git.source - if repo_source != self.local_source: + if repo_source != enums.GitService.LOCAL: logger.info(f"Tokenizing content from host: {repo_source}") contents = self.tokenize_content(contents) @@ -42,6 +41,16 @@ def analyze(self, temp_dir: str) -> List[Dict]: return contents + def get_dependencies( + self, temp_dir: str = None + ) -> Tuple[List[str], Dict[str, str]]: + """Extracts the dependencies of the user's repository.""" + contents = self.analyze(temp_dir) + dependencies = self.get_dependency_file_contents(contents) + attributes = ["extension", "language", "name"] + dependencies.extend(self.get_unique_contents(contents, attributes)) + return list(set(dependencies)), self.get_file_contents(contents) + def generate_contents(self, repo_path: str) -> List[Dict]: """Generates a List of Dict of file information.""" repo_path = Path(repo_path) @@ -79,13 +88,6 @@ def get_dependency_file_contents(self, contents: List[Dict]) -> List[str]: ) return flatten_list(parsed_contents) - def parse_content(content: Dict) -> List[str]: - """Helper function to parse the content of a file.""" - parser = PARSERS.get(content["name"]) - if parser: - return parser.parse(content["content"]) - return [] - def generate_file_info( self, repo_path: Path ) -> Generator[Tuple[str, Path, str], None, None]: @@ -120,27 +122,12 @@ def get_unique_contents( unique_contents = {data[key] for key in keys for data in contents} return list(unique_contents) - def get_dependencies( - self, temp_dir: str = None - ) -> Tuple[List[str], Dict[str, str]]: - """Extracts the dependencies of the user's repository.""" - contents = self.analyze(temp_dir) - dependencies = self.get_dependency_file_contents(contents) - attributes = ["extension", "language", "name"] - dependencies.extend(self.get_unique_contents(contents, attributes)) - return list(set(dependencies)), self.get_file_contents(contents) - def process_language_mapping(self, contents: List[Dict]) -> List[Dict]: """Maps file extensions to their programming languages.""" for content in contents: content["language"] = self.language_names.get( content["extension"], "" ).lower() - setup = self.language_setup.get(content["language"], "") - setup = setup if isinstance(setup, list) else [None, None] - while len(setup) < 3: - setup.append(None) - content["install"], content["run"], content["test"] = setup return contents def tokenize_content(self, contents: List[Dict]) -> List[Dict]: diff --git a/readmeai/main.py b/readmeai/main.py index 61c76107..4179ebce 100644 --- a/readmeai/main.py +++ b/readmeai/main.py @@ -11,20 +11,19 @@ import traceback from readmeai.cli.options import prompt_for_custom_image +from readmeai.config.enums import ImageOptions from readmeai.config.settings import ( AppConfig, AppConfigModel, ConfigHelper, GitSettings, - ImageOptions, load_config, load_config_helper, ) from readmeai.core.logger import Logger from readmeai.core.model import ModelHandler from readmeai.core.preprocess import RepoProcessor -from readmeai.markdown.headers import build_readme_md -from readmeai.markdown.tree import TreeGenerator +from readmeai.markdown.builder import ReadmeBuilder, build_readme_md from readmeai.services.git_operations import clone_repo_to_temp_dir logger = Logger(__name__) @@ -37,14 +36,15 @@ async def readme_agent(conf: AppConfig, conf_helper: ConfigHelper) -> None: try: await clone_repo_to_temp_dir(repo_url, temp_dir) - tree_command(conf, conf_helper, temp_dir) - parser = RepoProcessor(conf, conf_helper) dependencies, file_context = parser.get_dependencies(temp_dir) summaries = [(path, content) for path, content in file_context.items()] + repo_tree = ReadmeBuilder( + conf, conf_helper, dependencies, summaries, temp_dir + ).md_tree logger.info(f"Project dependencies: {dependencies}") - logger.info(f"Project structure:\n{conf.md.tree}") + logger.info(f"Project structure:\n{repo_tree}") async with ModelHandler(conf).use_api() as llm: if conf.cli.offline is False: @@ -53,7 +53,7 @@ async def readme_agent(conf: AppConfig, conf_helper: ConfigHelper) -> None: "type": "summaries", "context": { "repo": repo_url, - "tree": conf.md.tree, + "tree": repo_tree, "dependencies": dependencies, "summaries": summaries, }, @@ -62,9 +62,8 @@ async def readme_agent(conf: AppConfig, conf_helper: ConfigHelper) -> None: "type": "features", "context": { "repo": repo_url, - "tree": conf.md.tree, - "dependencies": dependencies, - "summaries": summaries, + "tree": repo_tree, + "summaries": file_context, }, }, { @@ -110,7 +109,7 @@ async def readme_agent(conf: AppConfig, conf_helper: ConfigHelper) -> None: conf.md.default, ) - build_readme_md(conf, conf_helper, dependencies, summaries) + build_readme_md(conf, conf_helper, dependencies, summaries, temp_dir) except Exception as exc_info: logger.error( @@ -153,10 +152,10 @@ def main( repository, temperature, ) - export_to_environment(conf, api_key) - log_settings(conf) + export_to_environment(conf, api_key) + asyncio.run(readme_agent(conf, conf_helper)) except Exception as exc_info: @@ -204,19 +203,6 @@ def export_to_environment(config: AppConfig, api_key: str) -> None: config.cli.offline = True -def tree_command( - conf: AppConfig, conf_helper: ConfigHelper, temp_dir: str -) -> None: - """Updates the markdown tree configuration.""" - tree_generator = TreeGenerator( - conf_helper=conf_helper, - root_dir=temp_dir, - repo_url=conf.git.repository, - repo_name=conf.git.name, - ) - conf.md.tree = conf.md.tree.format(tree_generator.run()) - - def log_settings(conf: AppConfig) -> None: """Log the settings for the CLI application.""" logger.info("Starting README-AI processing...") @@ -228,3 +214,4 @@ def log_settings(conf: AppConfig) -> None: logger.info(f"Header alignment: {conf.md.align}") logger.info(f"Using emojis: {conf.cli.emojis}") logger.info(f"Offline mode: {conf.cli.offline}") + logger.info(f"Repository validations: {conf.git}") diff --git a/readmeai/services/git_utilities.py b/readmeai/services/git_utilities.py index 96ae60a5..db477a33 100644 --- a/readmeai/services/git_utilities.py +++ b/readmeai/services/git_utilities.py @@ -1,9 +1,6 @@ """Git service utilities to retrieve repository metadata.""" -import os -import re from pathlib import Path -from typing import Tuple from readmeai.config.settings import GitService from readmeai.core.logger import Logger @@ -14,37 +11,17 @@ def get_remote_file_url(file_path: str, full_name: str, repo_url: str) -> str: """Returns the URL of the file in the remote repository.""" if Path(repo_url).exists(): - return GitService.LOCAL.file_url.format(file_path=file_path) + return GitService.LOCAL.file_url_template.format(file_path=file_path) for service in GitService: - if service.host in repo_url: - return service.file_url.format( + if service in repo_url: + return service.file_url_template.format( full_name=full_name, file_path=file_path ) raise ValueError("Unsupported Git service for URL generation.") -def get_remote_full_name(url_or_path) -> Tuple[str, str]: - """Returns the full name of the repository.""" - if os.path.exists(url_or_path): - return "local", os.path.basename(url_or_path) - - patterns = { - GitService.GITHUB.host: r"https?://github.com/([^/]+)/([^/]+)", - GitService.GITLAB.host: r"https?://gitlab.com/([^/]+)/([^/]+)", - GitService.BITBUCKET.host: r"https?://bitbucket.org/([^/]+)/([^/]+)", - } - - for host, pattern in patterns.items(): - match = re.match(pattern, url_or_path) - if match: - username, repo_name = match.groups() - return username, repo_name - - raise ValueError("Error: invalid repository URL or path.") - - async def parse_repo_url(repo_url: str) -> str: """Parses the repository URL and returns the API URL.""" try: @@ -52,7 +29,7 @@ async def parse_repo_url(repo_url: str) -> str: repo_name = f"{parts[-2]}/{parts[-1]}" for service in GitService: - if service.host in repo_url: + if service in repo_url: api_url = f"{service.api_url}{repo_name}" logger.info(f"{service.name.upper()} API URL: {api_url}") return api_url diff --git a/readmeai/settings/config.toml b/readmeai/settings/config.toml index 2757eeff..70d45459 100644 --- a/readmeai/settings/config.toml +++ b/readmeai/settings/config.toml @@ -54,7 +54,7 @@ header = """\ badge_color = "0080ff" badge_style = "standard" -badges_software = """\tDeveloped with the software and tools below\n
\n\n\t{badges}""" +badges_software = """\tDeveloped with the software and tools below.\n
\n\n\t{badges}""" badges_shields = """ \t @@ -228,24 +228,20 @@ Generate your response as a Markdown table with the following columns: | | Feature | Description | |----|--------------------|--------------------------------------------------------------------------------------------------------------------| -| ⚙️ | **Architecture** | Analyze the structural design of the system here. The description should not exceed 110 characters in total. | -| 📄 | **Documentation** | Discuss the quality and comprehensiveness of the documentation here. The description should not exceed 110 characters in total.| -| 🔗 | **Dependencies** | Examine the external libraries or other systems that this system relies on here. The description should not exceed 110 characters in total.| -| 🧩 | **Modularity** | Discuss the system's organization into smaller, interchangeable components here. The description should not exceed 110 characters in total.| -| 🧪 | **Testing** | Evaluate the system's testing strategies and tools here. The description should not exceed 110 characters in total. | -| ⚡️ | **Performance** | Analyze how well the system performs, considering speed, efficiency, and resource usage here. The description should not exceed 110 characters in total.| -| 🔐 | **Security** | Assess the measures the system uses to protect data and maintain functionality here. The description should not exceed 110 characters in total.| -| 🔀 | **Version Control**| Discuss the system's version control strategies and tools here. The description should not exceed 110 characters in total.| -| 🔌 | **Integrations** | Evaluate how the system interacts with other systems and services here. The description should not exceed 110 characters in total.| -| 📶 | **Scalability** | Analyze the system's ability to handle growth here. The description should not exceed 110 characters in total. | +| ⚙️ | **Architecture** | Analyze the structural design of the system here. The description should not exceed 99 characters in total. | +| 📄 | **Documentation** | Discuss the quality and comprehensiveness of the documentation here. The description should not exceed 99 characters in total.| +| 🔗 | **Dependencies** | Examine the external libraries or other systems that this system relies on here. The description should not exceed 99 characters in total.| +| 🧩 | **Modularity** | Discuss the system's organization into smaller, interchangeable components here. The description should not exceed 99 characters in total.| +| 🧪 | **Testing** | Evaluate the system's testing strategies and tools here. The description should not exceed 99 characters in total. | +| ⚡️ | **Performance** | Analyze how well the system performs, considering speed, efficiency, and resource usage here. The description should not exceed 99 characters in total.| +| 🔐 | **Security** | Assess the measures the system uses to protect data and maintain functionality here. The description should not exceed 99 characters in total.| +| 🔀 | **Version Control**| Discuss the system's version control strategies and tools here. The description should not exceed 99 characters in total.| +| 🔌 | **Integrations** | Evaluate how the system interacts with other systems and services here. The description should not exceed 99 characters in total.| +| 📶 | **Scalability** | Analyze the system's ability to handle growth here. The description should not exceed 99 characters in total. | Provided information includes: - -- Repository directory structure: {1} -- Repository dependencies and software: {2} -- Codebase file summaries: {3} - -Thank you for your time and effort! +Repository directory structure: {1} +Code files: {2} """ overview = """Analyze the codebase named {} ({}) and provide a robust, yet succinct overview of the project.\n Craft a paragraph, 80 TOKEN MAXIMUM, that encapsulate the core functionalities of the project, its purpose and value proposition.\n diff --git a/tests/conftest.py b/tests/conftest.py index 9dbb9af3..316e21ee 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -36,3 +36,9 @@ def mock_summaries(): ("/path/to/file2.py", "This is summary for file2.py"), (".github/workflows/ci.yml", "This is summary for ci.yml"), ] + + +@pytest.fixture(scope="session") +def mock_temp_dir(tmp_path_factory): + """Returns a temporary directory for the test session.""" + return tmp_path_factory.mktemp("test_readmeai_temp_dir") diff --git a/tests/test_config/test_enums.py b/tests/test_config/test_enums.py new file mode 100644 index 00000000..87d9eb5d --- /dev/null +++ b/tests/test_config/test_enums.py @@ -0,0 +1,61 @@ +"""Unit tests for enums and configuration settings.""" + +import pytest + +from readmeai.config.enums import BadgeOptions, GitService, ImageOptions + + +@pytest.mark.parametrize( + "service, expected_api_url", + [ + (GitService.LOCAL, None), + (GitService.GITHUB, "https://api.github.com/repos/"), + (GitService.GITLAB, "https://api.gitlab.com/v4/projects/"), + (GitService.BITBUCKET, "https://api.bitbucket.org/2.0/repositories/"), + ], +) +def test_git_service_api_url(service, expected_api_url): + """Test the API URL for the Git service.""" + assert service.api_url == expected_api_url + + +@pytest.mark.parametrize( + "service, expected_file_url_template", + [ + (GitService.LOCAL, "{file_path}"), + ( + GitService.GITHUB, + "https://github.com/{full_name}/blob/main/{file_path}", + ), + ( + GitService.GITLAB, + "https://gitlab.com/{full_name}/-/blob/master/{file_path}", + ), + ( + GitService.BITBUCKET, + "https://bitbucket.org/{full_name}/src/master/{file_path}", + ), + ], +) +def test_git_service_file_url_template(service, expected_file_url_template): + """Test the file URL template for the Git service.""" + assert service.file_url_template == expected_file_url_template + + +def test_badge_options(): + """Test the CLI options for badge icons.""" + assert BadgeOptions.DEFAULT == "default" + assert BadgeOptions.FLAT == "flat" + assert BadgeOptions.FLAT_SQUARE == "flat-square" + assert BadgeOptions.FOR_THE_BADGE == "for-the-badge" + assert BadgeOptions.PLASTIC == "plastic" + assert BadgeOptions.SKILLS == "skills" + assert BadgeOptions.SKILLS_LIGHT == "skills-light" + assert BadgeOptions.SOCIAL == "social" + + +def test_image_options(): + """Test the CLI options for header images.""" + assert ImageOptions.CUSTOM == "CUSTOM" + assert isinstance(ImageOptions.DEFAULT, str) + assert isinstance(ImageOptions.BLACK, str) diff --git a/tests/test_config/test_settings.py b/tests/test_config/test_settings.py index fcd83ef8..ef111c4f 100644 --- a/tests/test_config/test_settings.py +++ b/tests/test_config/test_settings.py @@ -2,19 +2,7 @@ import pytest -from readmeai.config.settings import BadgeOptions, GitSettings, load_config - - -def test_badge_options(): - """Test the CLI options for badge icons.""" - assert BadgeOptions.DEFAULT == "default" - assert BadgeOptions.FLAT == "flat" - assert BadgeOptions.FLAT_SQUARE == "flat-square" - assert BadgeOptions.FOR_THE_BADGE == "for-the-badge" - assert BadgeOptions.PLASTIC == "plastic" - assert BadgeOptions.SKILLS == "skills" - assert BadgeOptions.SKILLS_LIGHT == "skills-light" - assert BadgeOptions.SOCIAL == "social" +from readmeai.config.settings import GitSettings, load_config def test_git_settings_invalid_url(): diff --git a/tests/test_services/test_git_utilities.py b/tests/test_services/test_git_utilities.py index 59e6f0df..5ac04f2c 100644 --- a/tests/test_services/test_git_utilities.py +++ b/tests/test_services/test_git_utilities.py @@ -1,9 +1,6 @@ """Unit tests for git utility methods.""" -from readmeai.services.git_utilities import ( - get_remote_file_url, - get_remote_full_name, -) +from readmeai.services.git_utilities import get_remote_file_url def test_get_remote_file_url(): @@ -16,10 +13,3 @@ def test_get_remote_file_url(): "https://github.com/eli64s/readme-ai/blob/main/readmeai/main.py" ) assert file_url == expected_url - - -def test_get_remote_full_name(): - """Test method for getting the remote repository name.""" - repo_url = "https://github.com/eli64s/readme-ai" - expected_name = tuple(["eli64s", "readme-ai"]) - assert get_remote_full_name(repo_url) == expected_name