diff --git a/.gitignore b/.gitignore index ca5ee0a..0121179 100644 --- a/.gitignore +++ b/.gitignore @@ -160,7 +160,7 @@ cython_debug/ # be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore # and can be added to the global gitignore or merged into this file. For a more nuclear # option (not recommended) you can uncomment the following to ignore the entire idea folder. -#.idea/ +.idea/ # Ruff linter cache directory .ruff_cache diff --git a/pyproject.toml b/pyproject.toml index afa8564..e6608cb 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -9,6 +9,7 @@ dependencies = [ "requests>=2.31.0", "rich>=14.0.0", "dynaconf>=3.2.11", + "prettytable>=3.16.0", ] readme = "README.md" requires-python = ">= 3.11.5" diff --git a/src/github_rest_cli/api.py b/src/github_rest_cli/api.py index 893eaad..db94617 100644 --- a/src/github_rest_cli/api.py +++ b/src/github_rest_cli/api.py @@ -1,6 +1,6 @@ import requests from github_rest_cli.globals import GITHUB_URL, get_headers -from github_rest_cli.utils import rich_output, rprint +from github_rest_cli.utils import rich_output, CliOutput def request_with_handling( @@ -50,7 +50,7 @@ def fetch_user() -> str: return None -def get_repository(name: str, org: str = None): +def get_repository(name: str, org: str = None, output_format: str = "table"): owner = org if org else fetch_user() headers = get_headers() url = build_url("repos", owner, name) @@ -65,10 +65,40 @@ def get_repository(name: str, org: str = None): }, ) - if response: - data = response.json() - rprint(data) - return None + if not response: + return None + + data = response.json() + + if output_format == "json": + return CliOutput(data).get_json_output() + + return CliOutput([data]).default_format() + + +def list_repositories(page: int, property: str, role: str, output_format: str): + headers = get_headers() + url = build_url("user", "repos") + + params = {"per_page": page, "sort": property, "type": role} + + response = request_with_handling( + "GET", + url, + params=params, + headers=headers, + error_msg={401: "Unauthorized access. Please check your token or credentials."}, + ) + + if not response: + return None + + output = CliOutput(response.json()) + + if output_format == "json": + return output.json_format() + + return output.default_format() def create_repository(name: str, visibility: str, org: str = None, empty: bool = False): @@ -118,28 +148,6 @@ def delete_repository(name: str, org: str = None): ) -def list_repositories(page: int, property: str, role: str): - headers = get_headers() - url = build_url("user", "repos") - - params = {"per_page": page, "sort": property, "type": role} - - response = request_with_handling( - "GET", - url, - params=params, - headers=headers, - error_msg={401: "Unauthorized access. Please check your token or credentials."}, - ) - - if response: - data = response.json() - repo_full_name = [repo["full_name"] for repo in data] - for repos in repo_full_name: - rich_output(f"- {repos}") - rich_output(f"\nTotal repositories: {len(repo_full_name)}") - - def dependabot_security(name: str, enabled: bool, org: str = None): is_enabled = bool(enabled) diff --git a/src/github_rest_cli/main.py b/src/github_rest_cli/main.py index 4e5a4fb..a791fa3 100644 --- a/src/github_rest_cli/main.py +++ b/src/github_rest_cli/main.py @@ -29,7 +29,7 @@ def cli(): subparsers = parser.add_subparsers(help="GitHub REST API commands", dest="command") - # Subparser for "get-repository" function + # Subparser for "get-repo" function get_repo_parser = subparsers.add_parser( "get-repo", help="Get a repository's details" ) @@ -43,9 +43,18 @@ def cli(): get_repo_parser.add_argument( "-o", "--org", help="The organization name", required=False, dest="org" ) + get_repo_parser.add_argument( + "-f", + "--format", + required=False, + default="table", + choices=["table", "json"], + dest="format", + help="Format to display the repository in", + ) get_repo_parser.set_defaults(func=get_repository) - # Subparser for "list-repository" function + # Subparser for "list-repo" function list_repo_parser = subparsers.add_parser( "list-repo", help="List your repositories", @@ -61,7 +70,7 @@ def cli(): "-p", "--page", required=False, - default=50, + default=20, type=int, dest="page", help="The number of results", @@ -74,6 +83,15 @@ def cli(): dest="sort", help="List repositories sorted by", ) + list_repo_parser.add_argument( + "-f", + "--format", + required=False, + default="table", + choices=["table", "json"], + dest="format", + help="Format to display the list of repositories in", + ) list_repo_parser.set_defaults(func=list_repositories) # Subparser for "create-repository" function @@ -201,9 +219,13 @@ def cli(): if hasattr(args, "func"): if command == "get-repo": - args.func(args.name, args.org) + repo = args.func(args.name, args.org, args.format) + if repo is not None: + print(repo) # noqa: T201 elif command == "list-repo": - args.func(args.page, args.sort, args.role) + repos = args.func(args.page, args.sort, args.role, args.format) + if repos is not None: + print(repos) # noqa: T201 elif command == "create-repo": args.func(args.name, args.visibility, args.org, args.empty) elif command == "delete-repo": diff --git a/src/github_rest_cli/utils.py b/src/github_rest_cli/utils.py index fc23643..e37d392 100644 --- a/src/github_rest_cli/utils.py +++ b/src/github_rest_cli/utils.py @@ -1,5 +1,75 @@ from rich import print as rprint +import json + + +class CliFormatOutput: + def to_json(self, data): + return json.dumps(data, indent=2) + + def to_table(self, fields, data): + from prettytable import PrettyTable + + table = PrettyTable() + table.title = "GitHub Repositories" + table.header_style = "upper" + + if isinstance(fields, list): + table.field_names = fields + + # Align all columns to left. + table.align = "l" + + if isinstance(data, list): + for row in data: + table.add_row(row) + + return table + + +class CliOutput: + # What's the role of `data` here? + # `data` is a parameter of the __init__ method, provided when creating an instance. + # When you create an instance of the CliOutput class, + # `self.data` stores the value of the `data` parameter as an instance attribute. + def __init__(self, data): + self.data = data + self.formatter = CliFormatOutput() + + def json_format(self): + repos = { + "repositories": [ + { + "name": f.get("name"), + "owner": f.get("owner", {}).get("login"), + "url": f.get("html_url"), + "visibility": f.get("visibility"), + } + for f in self.data + ], + } + return self.formatter.to_json(repos) + + def table_format(self): + fields = ["name", "owner", "url", "visibility"] + values = [] + for repo in self.data: + values.append( + [ + repo.get("name"), + repo.get("owner", {}).get("login"), + repo.get("html_url"), + repo.get("visibility"), + ] + ) + + return self.formatter.to_table(fields, values) + + def get_json_output(self): + return self.formatter.to_json(self.data) + + def default_format(self): + return self.table_format() def rich_output(message: str, format_str: str = "bold green"): - rprint(f"[{format_str}]{message}[/{format_str}]") + return rprint(f"[{format_str}]{message}[/{format_str}]") diff --git a/tests/test_api.py b/tests/test_api.py index f70bb6f..a00f3e5 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -42,3 +42,62 @@ def test_create_repository_org(mocker): result = api.create_repository("test-repo", "public", "test-org") assert result == expected_message + + +SAMPLE_REPO = { + "name": "test-repo", + "owner": {"login": "test-user"}, + "html_url": "https://github.com/test-user/test-repo", + "visibility": "public", +} + + +def _mock_repo_response(mocker, payload): + mocker.patch(GET_HEADERS_FUNCTION, return_value={"Authorization": "token fake"}) + mocker.patch(FETCH_USER_FUNCTION, return_value="test-user") + + mock_response = mocker.Mock() + mock_response.status_code = 200 + mock_response.json.return_value = payload + mocker.patch(REQUEST_HANDLER_FUNCTION, return_value=mock_response) + + +def test_get_repository_json_format(mocker): + _mock_repo_response(mocker, SAMPLE_REPO) + + result = api.get_repository("test-repo", output_format="json") + + assert isinstance(result, str) + assert '"name": "test-repo"' in result + assert '"visibility": "public"' in result + + +def test_get_repository_table_format(mocker): + _mock_repo_response(mocker, SAMPLE_REPO) + + result = api.get_repository("test-repo", output_format="table") + + table_text = str(result) + assert "test-repo" in table_text + assert "test-user" in table_text + assert not table_text.strip().startswith("{") + + +def test_list_repositories_json_format(mocker): + _mock_repo_response(mocker, [SAMPLE_REPO]) + + result = api.list_repositories(20, "pushed", None, "json") + + assert isinstance(result, str) + assert '"repositories"' in result + assert '"name": "test-repo"' in result + + +def test_list_repositories_table_format(mocker): + _mock_repo_response(mocker, [SAMPLE_REPO]) + + result = api.list_repositories(20, "pushed", None, "table") + + table_text = str(result) + assert "test-repo" in table_text + assert "GITHUB REPOSITORIES" in table_text.upper() diff --git a/uv.lock b/uv.lock index 796d1ee..4d81366 100644 --- a/uv.lock +++ b/uv.lock @@ -88,6 +88,7 @@ version = "1.0.3" source = { editable = "." } dependencies = [ { name = "dynaconf" }, + { name = "prettytable" }, { name = "requests" }, { name = "rich" }, ] @@ -102,6 +103,7 @@ dev = [ [package.metadata] requires-dist = [ { name = "dynaconf", specifier = ">=3.2.11" }, + { name = "prettytable", specifier = ">=3.16.0" }, { name = "requests", specifier = ">=2.31.0" }, { name = "rich", specifier = ">=14.0.0" }, ] @@ -170,6 +172,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, ] +[[package]] +name = "prettytable" +version = "3.16.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "wcwidth" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/99/b1/85e18ac92afd08c533603e3393977b6bc1443043115a47bb094f3b98f94f/prettytable-3.16.0.tar.gz", hash = "sha256:3c64b31719d961bf69c9a7e03d0c1e477320906a98da63952bc6698d6164ff57", size = 66276, upload-time = "2025-03-24T19:39:04.008Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/02/c7/5613524e606ea1688b3bdbf48aa64bafb6d0a4ac3750274c43b6158a390f/prettytable-3.16.0-py3-none-any.whl", hash = "sha256:b5eccfabb82222f5aa46b798ff02a8452cf530a352c31bddfa29be41242863aa", size = 33863, upload-time = "2025-03-24T19:39:02.359Z" }, +] + [[package]] name = "pygments" version = "2.20.0" @@ -291,3 +305,12 @@ sdist = { url = "https://files.pythonhosted.org/packages/53/0c/06f8b233b8fd13b9e wheels = [ { url = "https://files.pythonhosted.org/packages/7f/3e/5db95bcf282c52709639744ca2a8b149baccf648e39c8cc87553df9eae0c/urllib3-2.7.0-py3-none-any.whl", hash = "sha256:9fb4c81ebbb1ce9531cce37674bbc6f1360472bc18ca9a553ede278ef7276897", size = 131087, upload-time = "2026-05-07T16:13:17.151Z" }, ] + +[[package]] +name = "wcwidth" +version = "0.2.13" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/6c/63/53559446a878410fc5a5974feb13d31d78d752eb18aeba59c7fef1af7598/wcwidth-0.2.13.tar.gz", hash = "sha256:72ea0c06399eb286d978fdedb6923a9eb47e1c486ce63e9b4e64fc18303972b5", size = 101301, upload-time = "2024-01-06T02:10:57.829Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fd/84/fd2ba7aafacbad3c4201d395674fc6348826569da3c0937e75505ead3528/wcwidth-0.2.13-py2.py3-none-any.whl", hash = "sha256:3da69048e4540d84af32131829ff948f1e022c1c6bdb8d6102117aac784f6859", size = 34166, upload-time = "2024-01-06T02:10:55.763Z" }, +]