From 31742efb5c6e2b39b66c37c147dc585ac7ed7620 Mon Sep 17 00:00:00 2001 From: Ritchie Mwewa <74001397+rly0nheart@users.noreply.github.com> Date: Sat, 28 Feb 2026 03:11:18 +0200 Subject: [PATCH] feat: add cli. tui can be run with -t/--tui --- pyproject.toml | 11 +- src/octosuite/__init__.py | 9 +- src/octosuite/_app.py | 20 --- src/octosuite/{core => api}/__init__.py | 0 src/octosuite/{core => api}/cache.py | 0 src/octosuite/{core => api}/github.py | 2 +- src/octosuite/{core => api}/models.py | 0 src/octosuite/app/__init__.py | 4 + src/octosuite/app/cli/__init__.py | 3 + src/octosuite/{_cli.py => app/cli/main.py} | 193 +++++++++------------ src/octosuite/{_lib.py => app/lib.py} | 32 ++-- src/octosuite/app/main.py | 15 ++ src/octosuite/{ => app}/tui/__init__.py | 6 +- src/octosuite/{ => app}/tui/dialogs.py | 0 src/octosuite/{ => app}/tui/menus.py | 13 +- src/octosuite/{ => app}/tui/prompts.py | 0 src/octosuite/meta.py | 4 + uv.lock | 10 +- 18 files changed, 157 insertions(+), 165 deletions(-) delete mode 100644 src/octosuite/_app.py rename src/octosuite/{core => api}/__init__.py (100%) rename src/octosuite/{core => api}/cache.py (100%) rename src/octosuite/{core => api}/github.py (99%) rename src/octosuite/{core => api}/models.py (100%) create mode 100644 src/octosuite/app/__init__.py create mode 100644 src/octosuite/app/cli/__init__.py rename src/octosuite/{_cli.py => app/cli/main.py} (72%) rename src/octosuite/{_lib.py => app/lib.py} (91%) create mode 100644 src/octosuite/app/main.py rename src/octosuite/{ => app}/tui/__init__.py (54%) rename src/octosuite/{ => app}/tui/dialogs.py (100%) rename src/octosuite/{ => app}/tui/menus.py (99%) rename src/octosuite/{ => app}/tui/prompts.py (100%) create mode 100644 src/octosuite/meta.py diff --git a/pyproject.toml b/pyproject.toml index ecae24b..816a0c3 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -9,11 +9,10 @@ authors = [ ] requires-python = ">=3.13" dependencies = [ - "rich>=14.2.0", + "rich>=14.3.3", "questionary>=2.1.1", "pyfiglet>=1.0.4", "update-checker>=0.18.0", - "black>=25.12.0", ] classifiers = [ "Development Status :: 5 - Production/Stable", @@ -24,9 +23,9 @@ classifiers = [ ] [project.urls] -homepage = "https://bellingcat.com" -issues = "https://github.com/bellingcat/octosuite/issues" -repository = "https://github.com/bellingcat/octosuite" +Homepage = "https://bellingcat.com" +Issues = "https://github.com/bellingcat/octosuite/issues" +Repository = "https://github.com/bellingcat/octosuite" [project.optional-dependencies] dev = [ @@ -34,7 +33,7 @@ dev = [ ] [project.scripts] -octosuite = "octosuite._app:start" +octosuite = "octosuite.app.main:start_app" [tool.uv] package = true \ No newline at end of file diff --git a/src/octosuite/__init__.py b/src/octosuite/__init__.py index 8a6eae9..acae45d 100644 --- a/src/octosuite/__init__.py +++ b/src/octosuite/__init__.py @@ -1,7 +1,4 @@ -__pkg__ = "octosuite" -__version__ = "5.0.0" +from .api.cache import cache +from .api.models import User, Org, Repo, Search -from .core.cache import cache -from .core.models import User, Org, Repo, Search - -__all__ = ["User", "Org", "Repo", "Search", "cache", "__pkg__", "__version__"] +__all__ = ["User", "Org", "Repo", "Search", "cache"] diff --git a/src/octosuite/_app.py b/src/octosuite/_app.py deleted file mode 100644 index ce9a22f..0000000 --- a/src/octosuite/_app.py +++ /dev/null @@ -1,20 +0,0 @@ -import sys - -from ._lib import console, __pkg__, __version__ -from .core.cache import cache - - -def start(): - """Entry point for octosuite.""" - - try: - console.set_window_title(title=f"{__pkg__.title()} CLI v{__version__}") - - from ._cli import run - - run() - - except KeyboardInterrupt: - sys.exit() - finally: - cache.clear() diff --git a/src/octosuite/core/__init__.py b/src/octosuite/api/__init__.py similarity index 100% rename from src/octosuite/core/__init__.py rename to src/octosuite/api/__init__.py diff --git a/src/octosuite/core/cache.py b/src/octosuite/api/cache.py similarity index 100% rename from src/octosuite/core/cache.py rename to src/octosuite/api/cache.py diff --git a/src/octosuite/core/github.py b/src/octosuite/api/github.py similarity index 99% rename from src/octosuite/core/github.py rename to src/octosuite/api/github.py index a2a6ce8..e5fd9a3 100644 --- a/src/octosuite/core/github.py +++ b/src/octosuite/api/github.py @@ -6,7 +6,7 @@ import requests from requests import Response from .cache import cache -from .._lib import __version__ +from ..meta import __version__ BASE_URL = "https://api.github.com" diff --git a/src/octosuite/core/models.py b/src/octosuite/api/models.py similarity index 100% rename from src/octosuite/core/models.py rename to src/octosuite/api/models.py diff --git a/src/octosuite/app/__init__.py b/src/octosuite/app/__init__.py new file mode 100644 index 0000000..c348599 --- /dev/null +++ b/src/octosuite/app/__init__.py @@ -0,0 +1,4 @@ +from . import cli, tui +from ..meta import __pkg__, __version__ + +__all__ = ["cli", "tui", "__pkg__", "__version__"] diff --git a/src/octosuite/app/cli/__init__.py b/src/octosuite/app/cli/__init__.py new file mode 100644 index 0000000..b493da4 --- /dev/null +++ b/src/octosuite/app/cli/__init__.py @@ -0,0 +1,3 @@ +from .main import run_cli, arg_parser + +__all__ = ["arg_parser", "run_cli"] diff --git a/src/octosuite/_cli.py b/src/octosuite/app/cli/main.py similarity index 72% rename from src/octosuite/_cli.py rename to src/octosuite/app/cli/main.py index c101131..8df4c5c 100644 --- a/src/octosuite/_cli.py +++ b/src/octosuite/app/cli/main.py @@ -1,22 +1,16 @@ -""" -Command-line interface for octosuite. - -Provides non-interactive access to GitHub data. -""" - import argparse import json import sys import typing as t -from rich.status import Status +from ..lib import export_response, preview_response, console, check_updates +from ...api.models import User, Org, Repo, Search +from ...meta import __pkg__, __version__ -from . import __pkg__, __version__ -from ._lib import export_response, preview_response, console -from .core.models import User, Org, Repo, Search +__all__ = ["arg_parser", "run_cli"] -def create_parser() -> argparse.ArgumentParser: +def arg_parser() -> argparse.ArgumentParser: """ Create the argument parser. @@ -35,7 +29,7 @@ def create_parser() -> argparse.ArgumentParser: version=f"%(prog)s {__version__}, MIT Licence © Bellingcat", ) parser.add_argument( - "-i", "--interactive", action="store_true", help="launch interactive TUI" + "-t", "--tui", action="store_true", help="launch interactive TUI" ) parser.add_argument( "-p", "--page", type=int, default=1, help="page number (default: %(default)s)" @@ -361,136 +355,121 @@ def output( preview_response(data=data, source=source, _type=data_type) -def run(): +def run_cli(args: argparse.Namespace): """Run the CLI.""" - parser = create_parser() - args = parser.parse_args() - - if args.interactive: - from .tui import run as run_tui - - run_tui() - return - - if not args.command: - parser.print_help() - return - try: - if args.command == "user": - user = User(name=args.username) + with console.status("Initialising…") as status: + check_updates(is_cli=True, status=status) + if args.command == "user": + user = User(name=args.username) - with Status( - f"[dim]Validating user ({args.username})[/dim]…", console=console - ): + status.update(f"[dim]Validating user ({args.username})…[/dim]") exists, _ = user.exists() - if not exists: - console.print(f"[red]User '{args.username}' not found[/red]") - sys.exit(1) + if not exists: + console.print(f"[red]User '{args.username}' not found[/red]") + sys.exit(1) - method = getattr(user, args.data_type) - with Status( - f"[dim]Getting {args.data_type} from {args.username}[/dim]…", - console=console, - ): + method = getattr(user, args.data_type) + status.update( + f"[dim]Getting {args.data_type} from {args.username}…[/dim]" + ) data = ( method() if args.data_type == "profile" else method(page=args.page, per_page=min(args.per_page, 100)) ) - output( - data=data, - as_json=args.json, - source=args.username, - data_type=args.data_type, - export_dir=args.dir, - ) + output( + data=data, + as_json=args.json, + source=args.username, + data_type=args.data_type, + export_dir=args.dir, + ) - elif args.command == "repo": - if "/" not in args.repository: - console.print("[red]Repository must be in 'owner/name' format[/red]") - sys.exit(1) + elif args.command == "repo": + if "/" not in args.repository: + console.print( + "[red]Repository must be in 'owner/name' format[/red]" + ) + sys.exit(1) - owner, name = args.repository.split("/", 1) - repo = Repo(name=name, owner=owner) + owner, name = args.repository.split("/", 1) + repo = Repo(name=name, owner=owner) - with Status( - f"[dim]Validating repo ({args.repository})[/dim]…", console=console - ): + status.update(f"[dim]Validating repo ({args.repository})…[/dim]") exists, _ = repo.exists() - if not exists: - console.print(f"[red]Repository '{args.repository}' not found[/red]") - sys.exit(1) + if not exists: + console.print( + f"[red]Repository '{args.repository}' not found[/red]" + ) + sys.exit(1) - method = getattr(repo, args.data_type) - with Status( - f"[dim]Getting {args.data_type} from {args.repository}[/dim]…", - console=console, - ): + method = getattr(repo, args.data_type) + status.update( + f"[dim]Getting {args.data_type} from {args.repository}…[/dim]" + ) data = ( method() if args.data_type in ("profile", "languages") else method(page=args.page, per_page=min(args.per_page, 100)) ) - output( - data=data, - as_json=args.json, - source=args.repository, - data_type=args.data_type, - export_dir=args.dir, - ) + output( + data=data, + as_json=args.json, + source=args.repository, + data_type=args.data_type, + export_dir=args.dir, + ) - elif args.command == "org": - org = Org(name=args.name) + elif args.command == "org": + org = Org(name=args.name) - with Status(f"[dim]Validating org ({args.name})[/dim]…", console=console): + status.update(f"[dim]Validating org ({args.name})…[/dim]") exists, _ = org.exists() - if not exists: - console.print(f"[red]Organisation '{args.name}' not found[/red]") - sys.exit(1) + if not exists: + console.print(f"[red]Organisation '{args.name}' not found[/red]") + sys.exit(1) - method = getattr(org, args.data_type) - with Status( - f"[dim]Getting {args.data_type} from {args.name}[/dim]…", - console=console, - ): + method = getattr(org, args.data_type) + status.update(f"[dim]Getting {args.data_type} from {args.name}…[/dim]") data = ( method() if args.data_type == "profile" else method(page=args.page, per_page=min(args.per_page, 100)) ) - output( - data=data, - as_json=args.json, - source=args.name, - data_type=args.data_type, - export_dir=args.dir, - ) + output( + data=data, + as_json=args.json, + source=args.name, + data_type=args.data_type, + export_dir=args.dir, + ) - elif args.command == "search": - search = Search( - query=args.query, - page=args.page, - per_page=min(args.per_page, 100), - ) - method = getattr(search, args.search_type) - with Status( - f"[dim]Searching {args.search_type} for '{args.query}'[/dim]…", - console=console, - ): + elif args.command == "search": + search = Search( + query=args.query, + page=args.page, + per_page=min(args.per_page, 100), + ) + method = getattr(search, args.search_type) + status.update( + f"[dim]Searching {args.search_type} for '{args.query}'…[/dim]" + ) result = method() - data = result.get("items", result) if isinstance(result, dict) else result - output( - data=data, - as_json=args.json, - source=args.query, - data_type=args.search_type, - export_dir=args.dir, - ) + data = ( + result.get("items", result) if isinstance(result, dict) else result + ) + output( + data=data, + as_json=args.json, + source=args.query, + data_type=args.search_type, + export_dir=args.dir, + ) except KeyboardInterrupt: console.print("\n[dim]Cancelled[/dim]") diff --git a/src/octosuite/_lib.py b/src/octosuite/app/lib.py similarity index 91% rename from src/octosuite/_lib.py rename to src/octosuite/app/lib.py index 2cb30ea..f9515eb 100644 --- a/src/octosuite/_lib.py +++ b/src/octosuite/app/lib.py @@ -9,15 +9,14 @@ from pathlib import Path import pyfiglet from prompt_toolkit.shortcuts import message_dialog from rich.console import Console +from rich.status import Status from rich.text import Text from rich.tree import Tree from update_checker import UpdateChecker -from . import __pkg__, __version__ +from ..meta import __pkg__, __version__ __all__ = [ - "__pkg__", - "__version__", "console", "preview_response", "export_response", @@ -213,17 +212,28 @@ def fill_tree(tree: Tree, data: t.Union[dict, list]) -> Tree: return tree -def check_updates(): - """Check for available package updates and display the result.""" +def check_updates(is_cli: bool = False, status: t.Optional[Status] = None): + """ + Check for available package updates and display the result. - with console.status("[dim]Checking for updates[/dim]…") as status: - checker = UpdateChecker() - result = checker.check(__pkg__, __version__) - if result is not None: - status.stop() + :param status: The rich.status object for showing a live status. + :param is_cli: Whether we're running as a CLI. + """ + + if isinstance(status, Status): + status.update("[dim]Checking for updates[/dim]…") + + checker = UpdateChecker() + result = checker.check(__pkg__, __version__) + if result is not None: + if not is_cli: + if isinstance(status, Status): + status.stop() message_dialog(title="Update Available", text=str(result)).run() else: - status.stop() + console.print(result) + else: + if not is_cli: message_dialog( title="Up to Date", text=f"You're running the current version, {__version__}", diff --git a/src/octosuite/app/main.py b/src/octosuite/app/main.py new file mode 100644 index 0000000..d347cb3 --- /dev/null +++ b/src/octosuite/app/main.py @@ -0,0 +1,15 @@ +from .cli import run_cli +from .cli.main import arg_parser +from .tui import run_tui + + +def start_app(): + parser = arg_parser() + args = parser.parse_args() + + if args.tui: + run_tui() + if args.command: + run_cli(args=args) + else: + parser.print_usage() diff --git a/src/octosuite/tui/__init__.py b/src/octosuite/app/tui/__init__.py similarity index 54% rename from src/octosuite/tui/__init__.py rename to src/octosuite/app/tui/__init__.py index 360d6da..2339606 100644 --- a/src/octosuite/tui/__init__.py +++ b/src/octosuite/app/tui/__init__.py @@ -1,11 +1,9 @@ -"""Terminal user interface for octosuite.""" - from .menus import Menus -__all__ = ["run", "Menus"] +__all__ = ["run_tui", "Menus"] -def run(): +def run_tui(): """Run the interactive TUI.""" menu = Menus() diff --git a/src/octosuite/tui/dialogs.py b/src/octosuite/app/tui/dialogs.py similarity index 100% rename from src/octosuite/tui/dialogs.py rename to src/octosuite/app/tui/dialogs.py diff --git a/src/octosuite/tui/menus.py b/src/octosuite/app/tui/menus.py similarity index 99% rename from src/octosuite/tui/menus.py rename to src/octosuite/app/tui/menus.py index 6e7308e..f75d175 100644 --- a/src/octosuite/tui/menus.py +++ b/src/octosuite/app/tui/menus.py @@ -5,12 +5,17 @@ import questionary as q from questionary import Style from rich.status import Status +from octosuite.api.cache import cache +from octosuite.api.models import User, Repo, Org, Search +from octosuite.app.lib import ( + check_updates, + preview_response, + export_response, + set_menu_title, +) +from octosuite.app.lib import console, clear_screen, ascii_banner from .dialogs import Dialogs from .prompts import Prompts -from .._lib import check_updates, preview_response, export_response, set_menu_title -from .._lib import console, clear_screen, ascii_banner -from ..core.cache import cache -from ..core.models import User, Repo, Org, Search CUSTOM_STYLE = Style( [ diff --git a/src/octosuite/tui/prompts.py b/src/octosuite/app/tui/prompts.py similarity index 100% rename from src/octosuite/tui/prompts.py rename to src/octosuite/app/tui/prompts.py diff --git a/src/octosuite/meta.py b/src/octosuite/meta.py new file mode 100644 index 0000000..21a21be --- /dev/null +++ b/src/octosuite/meta.py @@ -0,0 +1,4 @@ +from importlib.metadata import version + +__pkg__ = "octosuite" +__version__ = version(__pkg__) diff --git a/uv.lock b/uv.lock index 8895fb7..5929796 100644 --- a/uv.lock +++ b/uv.lock @@ -144,7 +144,6 @@ name = "octosuite" version = "5.0.0" source = { editable = "." } dependencies = [ - { name = "black" }, { name = "pyfiglet" }, { name = "questionary" }, { name = "rich" }, @@ -158,11 +157,10 @@ dev = [ [package.metadata] requires-dist = [ - { name = "black", specifier = ">=25.12.0" }, { name = "black", marker = "extra == 'dev'", specifier = ">=26.1.0" }, { name = "pyfiglet", specifier = ">=1.0.4" }, { name = "questionary", specifier = ">=2.1.1" }, - { name = "rich", specifier = ">=14.2.0" }, + { name = "rich", specifier = ">=14.3.3" }, { name = "update-checker", specifier = ">=0.18.0" }, ] provides-extras = ["dev"] @@ -262,15 +260,15 @@ wheels = [ [[package]] name = "rich" -version = "14.2.0" +version = "14.3.3" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "markdown-it-py" }, { name = "pygments" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/fb/d2/8920e102050a0de7bfabeb4c4614a49248cf8d5d7a8d01885fbb24dc767a/rich-14.2.0.tar.gz", hash = "sha256:73ff50c7c0c1c77c8243079283f4edb376f0f6442433aecb8ce7e6d0b92d1fe4", size = 219990, upload-time = "2025-10-09T14:16:53.064Z" } +sdist = { url = "https://files.pythonhosted.org/packages/b3/c6/f3b320c27991c46f43ee9d856302c70dc2d0fb2dba4842ff739d5f46b393/rich-14.3.3.tar.gz", hash = "sha256:b8daa0b9e4eef54dd8cf7c86c03713f53241884e814f4e2f5fb342fe520f639b", size = 230582, upload-time = "2026-02-19T17:23:12.474Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/25/7a/b0178788f8dc6cafce37a212c99565fa1fe7872c70c6c9c1e1a372d9d88f/rich-14.2.0-py3-none-any.whl", hash = "sha256:76bc51fe2e57d2b1be1f96c524b890b816e334ab4c1e45888799bfaab0021edd", size = 243393, upload-time = "2025-10-09T14:16:51.245Z" }, + { url = "https://files.pythonhosted.org/packages/14/25/b208c5683343959b670dc001595f2f3737e051da617f66c31f7c4fa93abc/rich-14.3.3-py3-none-any.whl", hash = "sha256:793431c1f8619afa7d3b52b2cdec859562b950ea0d4b6b505397612db8d5362d", size = 310458, upload-time = "2026-02-19T17:23:13.732Z" }, ] [[package]]