Compare commits

..

22 Commits
4.0.0b2 ... dev

Author SHA1 Message Date
Ritchie Mwewa
4248b10476 chore: show banner in cli 2026-02-28 03:21:10 +02:00
Ritchie Mwewa
054d299a04 chore: show banner in cli 2026-02-28 03:20:48 +02:00
Ritchie Mwewa
62491de83f feat: add cli. tui can be run with -t/--tui 2026-02-28 03:12:25 +02:00
Ritchie Mwewa
31742efb5c feat: add cli. tui can be run with -t/--tui 2026-02-28 03:11:18 +02:00
Ritchie Mwewa
0caf6f7745 Merge pull request #28 from bellingcat/5.0/beta
5.0/beta
2026-01-24 00:21:44 +02:00
Ritchie Mwewa
307a59cd9f Update README.md 2026-01-24 00:16:29 +02:00
Ritchie Mwewa
0470189396 Update README.md 2026-01-23 23:42:13 +02:00
Ritchie Mwewa
e21305669f Update _cli.py 2026-01-23 23:38:34 +02:00
Ritchie Mwewa
2ecce50686 Update pyproject.toml 2026-01-23 23:24:11 +02:00
Ritchie Mwewa
5464932b09 Update README.md 2026-01-23 23:22:59 +02:00
Ritchie Mwewa
fb3f1a39af 5.0 beta 2026-01-23 23:16:48 +02:00
Ritchie Mwewa
eed3ee8e1c 4.0 stable 2026-01-06 22:31:38 +02:00
Ritchie Mwewa
5bb0b6db6b 4.0 stable 2026-01-06 22:19:11 +02:00
Ritchie Mwewa
1916a7e1a6 4.0/release-candidate 2026-01-06 22:12:49 +02:00
Ritchie Mwewa
400829c6ec 4.0/release-candidate 2026-01-06 22:10:52 +02:00
Ritchie Mwewa
914e320b9e Merge pull request #27 from bellingcat/4.0/release-candidate
4.0/release-candidate
2026-01-06 21:44:48 +02:00
Ritchie Mwewa
170148172d Merge branch 'master' into 4.0/release-candidate 2026-01-06 21:44:39 +02:00
Ritchie Mwewa
4d589beda5 4.0/release-candidate 2026-01-06 21:36:25 +02:00
Ritchie Mwewa
4f207d5343 Merge pull request #26 from bellingcat/4.0/beta
4.0/beta
2026-01-04 06:07:56 +02:00
Ritchie Mwewa
9de7bf1bfc Merge branch 'master' into 4.0/beta 2026-01-04 06:07:45 +02:00
Ritchie Mwewa
8d0695324a Merge branch '4.0/beta' of github.com:bellingcat/octosuite into 4.0/beta 2026-01-04 06:06:29 +02:00
Ritchie Mwewa
6ce80a1eea Patch for bug that called the 'Press ENTER to continue...' prompt, when we skip exporting response 2026-01-04 06:05:18 +02:00
21 changed files with 710 additions and 170 deletions

View File

@@ -1,6 +1,6 @@
MIT License MIT License
Copyright (c) 2025 Bellingcat Copyright (c) 2026 Bellingcat
Permission is hereby granted, free of charge, to any person obtaining a copy Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal of this software and associated documentation files (the "Software"), to deal

202
README.md
View File

@@ -1,122 +1,138 @@
![octosuite](https://raw.githubusercontent.com/bellingcat/octosuite/refs/heads/master/img/octosuite.png) ![octosuite](https://raw.githubusercontent.com/bellingcat/octosuite/refs/heads/master/img/octosuite.png)
TUI-based toolkit for GitHub data analysis. Terminal-based toolkit for GitHub data analysis.
![PyPI - Version](https://img.shields.io/pypi/v/octosuite) ![PyPI - Version](https://img.shields.io/pypi/v/octosuite)
![PyPI - Downloads](https://img.shields.io/pepy/dt/octosuite) ![PyPI - Downloads](https://img.shields.io/pepy/dt/octosuite)
![Code Size](https://img.shields.io/github/languages/code-size/bellingcat/octosuite) ![Code Size](https://img.shields.io/github/languages/code-size/bellingcat/octosuite)
![Release Date](https://img.shields.io/github/release-date/bellingcat/octosuite) ![Release Date](https://img.shields.io/github/release-date/bellingcat/octosuite)
![Build Status](https://img.shields.io/github/actions/workflow/status/bellingcat/octosuite/python-publish.yml) ![Build Status](https://img.shields.io/github/actions/workflow/status/bellingcat/octosuite/python-publish.yml)
[![CodeQL Advanced](https://github.com/bellingcat/octosuite/actions/workflows/codeql.yml/badge.svg)](https://github.com/bellingcat/octosuite/actions/workflows/codeql.yml)
![License](https://img.shields.io/github/license/bellingcat/octosuite) ![License](https://img.shields.io/github/license/bellingcat/octosuite)
## Overview ```shell
$ octosuite user torvalds
```
OctoSuite provides a terminal interface for exploring and exporting GitHub data. Access information about users, ```python
repositories, organizations, and search across GitHub's platform. from pprint import pprint
import octosuite
## Features user = octosuite.User(name="torvalds")
exists, profile = user.exists()
<details> if exists:
<summary><strong>See details</strong></summary> pprint(profile)
```
- **User** - Get user data
- Profile
- Repositories
- Subscriptions
- Starred
- Followers
- Following
- Organizations
- Gists
- Events
- Received Events
- **Repository** - Get repository data
- Profile
- Forks
- Issues
- Issue Events
- Events
- Assignees
- Branches
- Tags
- Languages
- Stargazers
- Subscribers
- Commits
- Comments
- Releases
- Deployments
- Labels
- **Organisation** - Get organisation data
- Profile
- Repositories
- Events
- Hooks
- Issues
- Members
- **Search** - Search GitHub
- Repositories
- Users
- Commits
- Issues
- Topics
- **Export** - Export data
- JSON
- CSV
- HTML
</details>
## Installation ## Installation
### PyPI
```bash ```bash
pip install octosuite pip install octosuite
``` ```
### Build from source
```bash
# Clone repository
git clone https://github.com/bellingcat/octosuite.git
# Move to octosuite directory
cd octosuite
# Build and install (uses uv)
make install
# If you dont have uv installed, you can install directly with pip:
pip install .
# Run
octosuite
```
> [!Note]
> You can then run octosuite with command `octosuite`
## Usage ## Usage
Navigate using <kbd>UP</kbd><kbd>DOWN</kbd> and <kbd>ENTER</kbd> to select options. In the export menu, you should ### TUI (Interactive)
use <kbd>SPACE</kbd> to check the format you want.
The interface guides you through Launch the interactive terminal interface:
selecting a
data source
and
choosing what information to retrieve. Preview the results and optionally export them in your preferred format.
![home](https://raw.githubusercontent.com/bellingcat/octosuite/refs/heads/master/img/menu.png) ```bash
octosuite -t/--tui
```
## License Navigate using arrow keys and Enter to select options.
### MIT License ### CLI
See the LICENSE file for details. License information is also available through the application's main menu. Query GitHub data directly from the command line:
## Contributing ```bash
# User data
octosuite user torvalds
octosuite user torvalds --repos --page 1 --per-page 50
octosuite user torvalds --followers --json
Contributions are welcome. Please submit pull requests or open issues for bugs and feature requests. Good luck! # Repository data
octosuite repo torvalds/linux
octosuite repo torvalds/linux --commits
octosuite repo torvalds/linux --stargazers --export ./data
# Organisation data
octosuite org github
octosuite org github --members --json
# Search
octosuite search "machine learning" --repos
octosuite search "python cli" --users --json
```
**Common options:**
- `--page` - Page number (default: 1)
- `--per-page` - Results per page, max 100 (default: 100)
- `--json` - Output as JSON
- `--export DIR` - Export to directory
Run `octosuite <command> --help` for available data type flags.
### Library
Use octosuite in your Python projects:
```python
from octosuite import User, Repo, Org, Search
# Get user data
user = User("torvalds")
exists, profile = user.exists()
if exists:
repos = user.repos(page=1, per_page=100)
followers = user.followers(page=1, per_page=50)
# Get repository data
repo = Repo(name="linux", owner="torvalds")
exists, profile = repo.exists()
if exists:
commits = repo.commits(page=1, per_page=100)
languages = repo.languages()
# Get organisation data
org = Org("github")
exists, profile = org.exists()
if exists:
members = org.members(page=1, per_page=100)
# Search GitHub
search = Search(query="machine learning", page=1, per_page=50)
results = search.repos()
```
## Features
<details>
<summary><strong>Data Types</strong></summary>
**User:** profile, repos, subscriptions, starred, followers, following, orgs, gists, events, received_events
**Repository:** profile, forks, issue_events, events, assignees, branches, tags, languages, stargazers, subscribers,
commits, comments, issues, releases, deployments, labels
**Organisation:** profile, repos, events, hooks, issues, members
**Search:** repos, users, commits, issues, topics
</details>
<details>
<summary><strong>Export Formats</strong></summary>
- JSON
- CSV
- HTML
</details>
## Licence
MIT Licence. See the [LICENCE](https://raw.githubusercontent.com/bellingcat/octosuite/refs/heads/master/LICENSE) file
for details.

View File

@@ -1,7 +1,7 @@
[project] [project]
name = "octosuite" name = "octosuite"
version = "4.0.0beta2" version = "5.1.0"
description = "TUI-based toolkit for GitHub data analysis." description = "Terminal-based toolkit for GitHub data analysis."
readme = "README.md" readme = "README.md"
license = "MIT" license = "MIT"
authors = [ authors = [
@@ -9,13 +9,13 @@ authors = [
] ]
requires-python = ">=3.13" requires-python = ">=3.13"
dependencies = [ dependencies = [
"rich>=14.2.0", "rich>=14.3.3",
"questionary>=2.1.1", "questionary>=2.1.1",
"pyfiglet>=1.0.4", "pyfiglet>=1.0.4",
"update-checker>=0.18.0" "update-checker>=0.18.0",
] ]
classifiers = [ classifiers = [
"Development Status :: 4 - Beta", "Development Status :: 5 - Production/Stable",
"Programming Language :: Python :: 3", "Programming Language :: Python :: 3",
"Intended Audience :: End Users/Desktop", "Intended Audience :: End Users/Desktop",
"Operating System :: OS Independent", "Operating System :: OS Independent",
@@ -23,14 +23,17 @@ classifiers = [
] ]
[project.urls] [project.urls]
homepage = "https://bellingcat.com" Homepage = "https://bellingcat.com"
issues = "https://github.com/bellingcat/octosuite/issues" Issues = "https://github.com/bellingcat/octosuite/issues"
repository = "https://github.com/bellingcat/octosuite" Repository = "https://github.com/bellingcat/octosuite"
[project.optional-dependencies] [project.optional-dependencies]
dev = [ dev = [
"black>=25.12.0", "black>=26.1.0",
] ]
[project.scripts] [project.scripts]
octosuite = "octosuite.app:start" octosuite = "octosuite.app.main:start_app"
[tool.uv]
package = true

View File

@@ -1,2 +1,4 @@
__pkg__ = "octosuite" from .api.cache import cache
__version__ = "4.0.0beta2" from .api.models import User, Org, Repo, Search
__all__ = ["User", "Org", "Repo", "Search", "cache"]

View File

@@ -6,7 +6,7 @@ import requests
from requests import Response from requests import Response
from .cache import cache from .cache import cache
from ..lib import __version__ from ..meta import __version__
BASE_URL = "https://api.github.com" BASE_URL = "https://api.github.com"

View File

@@ -1,16 +0,0 @@
import sys
from .core.cache import cache
from .lib import console, __pkg__, __version__
from .tui.menus import Menus
def start():
try:
console.set_window_title(title=f"{__pkg__.title()} v{__version__}")
menu = Menus()
menu.main()
except KeyboardInterrupt:
sys.exit()
finally:
cache.clear()

View File

@@ -0,0 +1,4 @@
from . import cli, tui
from ..meta import __pkg__, __version__
__all__ = ["cli", "tui", "__pkg__", "__version__"]

View File

@@ -0,0 +1,3 @@
from .main import run_cli, arg_parser
__all__ = ["arg_parser", "run_cli"]

View File

@@ -0,0 +1,483 @@
import argparse
import json
import sys
import typing as t
from ..lib import (
export_response,
preview_response,
console,
check_updates,
ascii_banner,
)
from ...api.models import User, Org, Repo, Search
from ...meta import __pkg__, __version__
__all__ = ["arg_parser", "run_cli"]
def arg_parser() -> argparse.ArgumentParser:
"""
Create the argument parser.
:return: Configured ArgumentParser.
"""
parser = argparse.ArgumentParser(
prog=__pkg__,
description="Terminal-based toolkit for GitHub data analysis - for Bellingcat",
)
parser.add_argument(
"-v",
"--version",
action="version",
version=f"%(prog)s {__version__}, MIT Licence © Bellingcat",
)
parser.add_argument(
"-t", "--tui", action="store_true", help="launch interactive TUI"
)
parser.add_argument(
"-p", "--page", type=int, default=1, help="page number (default: %(default)s)"
)
parser.add_argument(
"-N",
"--per-page",
type=int,
default=100,
help="maximum number of results per page (default: %(default)s)",
)
parser.add_argument("-j", "--json", action="store_true", help="output as JSON")
parser.add_argument("-d", "--dir", metavar="DIR", help="export to directory")
subparsers = parser.add_subparsers(dest="command")
# User command
user_parser = subparsers.add_parser("user", help="get user data")
user_parser.add_argument("username", help="GitHub username")
user_group = user_parser.add_mutually_exclusive_group()
user_group.add_argument(
"--profile",
action="store_const",
const="profile",
dest="data_type",
help="profile data (default)",
)
user_group.add_argument(
"--repos",
action="store_const",
const="repos",
dest="data_type",
help="repositories",
)
user_group.add_argument(
"--subscriptions",
action="store_const",
const="subscriptions",
dest="data_type",
help="subscriptions",
)
user_group.add_argument(
"--starred",
action="store_const",
const="starred",
dest="data_type",
help="starred repos",
)
user_group.add_argument(
"--followers",
action="store_const",
const="followers",
dest="data_type",
help="followers",
)
user_group.add_argument(
"--following",
action="store_const",
const="following",
dest="data_type",
help="following",
)
user_group.add_argument(
"--orgs",
action="store_const",
const="orgs",
dest="data_type",
help="organisations",
)
user_group.add_argument(
"--gists", action="store_const", const="gists", dest="data_type", help="gists"
)
user_group.add_argument(
"--events",
action="store_const",
const="events",
dest="data_type",
help="events",
)
user_group.add_argument(
"--received-events",
action="store_const",
const="received_events",
dest="data_type",
help="received events",
)
user_parser.set_defaults(data_type="profile")
# Repo command
repo_parser = subparsers.add_parser("repo", help="get repository data")
repo_parser.add_argument("repository", help="repository (owner/name)")
repo_group = repo_parser.add_mutually_exclusive_group()
repo_group.add_argument(
"--profile",
action="store_const",
const="profile",
dest="data_type",
help="repo data (default)",
)
repo_group.add_argument(
"--forks", action="store_const", const="forks", dest="data_type", help="forks"
)
repo_group.add_argument(
"--issue-events",
action="store_const",
const="issue_events",
dest="data_type",
help="issue events",
)
repo_group.add_argument(
"--events",
action="store_const",
const="events",
dest="data_type",
help="events",
)
repo_group.add_argument(
"--assignees",
action="store_const",
const="assignees",
dest="data_type",
help="assignees",
)
repo_group.add_argument(
"--branches",
action="store_const",
const="branches",
dest="data_type",
help="branches",
)
repo_group.add_argument(
"--tags", action="store_const", const="tags", dest="data_type", help="tags"
)
repo_group.add_argument(
"--languages",
action="store_const",
const="languages",
dest="data_type",
help="languages",
)
repo_group.add_argument(
"--stargazers",
action="store_const",
const="stargazers",
dest="data_type",
help="stargazers",
)
repo_group.add_argument(
"--subscribers",
action="store_const",
const="subscribers",
dest="data_type",
help="subscribers",
)
repo_group.add_argument(
"--commits",
action="store_const",
const="commits",
dest="data_type",
help="commits",
)
repo_group.add_argument(
"--comments",
action="store_const",
const="comments",
dest="data_type",
help="comments",
)
repo_group.add_argument(
"--issues",
action="store_const",
const="issues",
dest="data_type",
help="issues",
)
repo_group.add_argument(
"--releases",
action="store_const",
const="releases",
dest="data_type",
help="releases",
)
repo_group.add_argument(
"--deployments",
action="store_const",
const="deployments",
dest="data_type",
help="deployments",
)
repo_group.add_argument(
"--labels",
action="store_const",
const="labels",
dest="data_type",
help="labels",
)
repo_parser.set_defaults(data_type="profile")
# Org command
org_parser = subparsers.add_parser("org", help="get organisation data")
org_parser.add_argument("name", help="organisation name")
org_group = org_parser.add_mutually_exclusive_group()
org_group.add_argument(
"--profile",
action="store_const",
const="profile",
dest="data_type",
help="profile data (default)",
)
org_group.add_argument(
"--repos",
action="store_const",
const="repos",
dest="data_type",
help="repositories",
)
org_group.add_argument(
"--events",
action="store_const",
const="events",
dest="data_type",
help="events",
)
org_group.add_argument(
"--hooks",
action="store_const",
const="hooks",
dest="data_type",
help="webhooks",
)
org_group.add_argument(
"--issues",
action="store_const",
const="issues",
dest="data_type",
help="issues",
)
org_group.add_argument(
"--members",
action="store_const",
const="members",
dest="data_type",
help="members",
)
org_parser.set_defaults(data_type="profile")
# Search command
search_parser = subparsers.add_parser("search", help="search GitHub")
search_parser.add_argument("query", help="search query")
search_group = search_parser.add_mutually_exclusive_group()
search_group.add_argument(
"--repos",
action="store_const",
const="repos",
dest="search_type",
help="search repositories (default)",
)
search_group.add_argument(
"--users",
action="store_const",
const="users",
dest="search_type",
help="search users",
)
search_group.add_argument(
"--commits",
action="store_const",
const="commits",
dest="search_type",
help="search commits",
)
search_group.add_argument(
"--issues",
action="store_const",
const="issues",
dest="search_type",
help="search issues",
)
search_group.add_argument(
"--topics",
action="store_const",
const="topics",
dest="search_type",
help="search topics",
)
search_parser.set_defaults(search_type="repos")
return parser
def output(
data: t.Union[dict, list],
as_json: bool,
source: str,
data_type: str,
export_dir: t.Optional[str] = None,
):
"""
Output data in the appropriate format.
:param data: Data to output.
:param as_json: Output as JSON.
:param source: Source identifier.
:param data_type: Type of data.
:param export_dir: Export directory.
"""
if not data:
console.print(f"[yellow]No {data_type} data found for '{source}'[/yellow]")
return
if export_dir:
export_response(
data=data,
data_type=data_type,
source=source,
file_formats=["json"],
output_dir=export_dir,
)
elif as_json:
print(json.dumps(data, indent=2, ensure_ascii=False))
else:
preview_response(data=data, source=source, _type=data_type)
def run_cli(args: argparse.Namespace):
"""Run the CLI."""
ascii_banner(text=__pkg__)
try:
with console.status("Initialising…") as status:
check_updates(is_cli=True, status=status)
if args.command == "user":
user = User(name=args.username)
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)
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,
)
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)
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)
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,
)
elif args.command == "org":
org = Org(name=args.name)
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)
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,
)
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,
)
except KeyboardInterrupt:
console.print("\n[dim]Cancelled[/dim]")
sys.exit(130)

View File

@@ -9,15 +9,14 @@ from pathlib import Path
import pyfiglet import pyfiglet
from prompt_toolkit.shortcuts import message_dialog from prompt_toolkit.shortcuts import message_dialog
from rich.console import Console from rich.console import Console
from rich.status import Status
from rich.text import Text from rich.text import Text
from rich.tree import Tree from rich.tree import Tree
from update_checker import UpdateChecker from update_checker import UpdateChecker
from . import __pkg__, __version__ from ..meta import __pkg__, __version__
__all__ = [ __all__ = [
"__pkg__",
"__version__",
"console", "console",
"preview_response", "preview_response",
"export_response", "export_response",
@@ -63,6 +62,7 @@ def preview_response(data: t.Union[dict, list], source: str, _type: str):
item.get("full_name") item.get("full_name")
or item.get("name") or item.get("name")
or item.get("login") or item.get("login")
or item.get("type")
or item.get("id") or item.get("id")
or "Item" or "Item"
) )
@@ -212,17 +212,28 @@ def fill_tree(tree: Tree, data: t.Union[dict, list]) -> Tree:
return tree return tree
def check_updates(): def check_updates(is_cli: bool = False, status: t.Optional[Status] = None):
"""Check for available package updates and display the result.""" """
Check for available package updates and display the result.
with console.status("[dim]Checking for updates...[/dim]") as status: :param status: The rich.status object for showing a live status.
checker = UpdateChecker() :param is_cli: Whether we're running as a CLI.
result = checker.check(__pkg__, __version__) """
if result is not None:
status.stop() if isinstance(status, Status):
message_dialog(title="Update Available", text=result).run() 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: else:
status.stop() console.print(result)
else:
if not is_cli:
message_dialog( message_dialog(
title="Up to Date", title="Up to Date",
text=f"You're running the current version, {__version__}", text=f"You're running the current version, {__version__}",

15
src/octosuite/app/main.py Normal file
View File

@@ -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()

View File

@@ -0,0 +1,10 @@
from .menus import Menus
__all__ = ["run_tui", "Menus"]
def run_tui():
"""Run the interactive TUI."""
menu = Menus()
menu.main()

View File

@@ -5,12 +5,17 @@ import questionary as q
from questionary import Style from questionary import Style
from rich.status import Status 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 .dialogs import Dialogs
from .prompts import Prompts from .prompts import Prompts
from ..core.cache import cache
from ..core.models import User, Repo, Org, Search
from ..lib import check_updates, preview_response, export_response, set_menu_title
from ..lib import console, clear_screen, ascii_banner
CUSTOM_STYLE = Style( CUSTOM_STYLE = Style(
[ [
@@ -99,7 +104,7 @@ class BaseMenu:
target_type = "org" target_type = "org"
with Status( with Status(
f"[dim]Validating {target_type} ({identifier})[/dim]...", f"[dim]Validating {target_type} ({identifier})[/dim]",
console=console, console=console,
) as status: ) as status:
exists, response = instance.exists() exists, response = instance.exists()
@@ -110,7 +115,7 @@ class BaseMenu:
console.print( console.print(
f"[bold][yellow]✘[/yellow] {response['message']}[/bold]" f"[bold][yellow]✘[/yellow] {response['message']}[/bold]"
) )
console.input(" Press [bold]ENTER[/bold] to continue ...") console.input(" Press [bold]ENTER[/bold] to continue ")
callback(*callback_args) callback(*callback_args)
return False return False
@@ -135,7 +140,7 @@ class BaseMenu:
valid_methods = self.paginated_methods | self.non_paginated_methods valid_methods = self.paginated_methods | self.non_paginated_methods
if method_name in valid_methods: if method_name in valid_methods:
with Status( with Status(
status=f"[dim]Initialising {target_type} {method_name}[/dim]...", status=f"[dim]Initialising {target_type} {method_name}[/dim]",
console=console, console=console,
) as status: ) as status:
data = self.execute_selection( data = self.execute_selection(
@@ -168,7 +173,7 @@ class BaseMenu:
prompts.pagination_params() if method_name in self.paginated_methods else {} prompts.pagination_params() if method_name in self.paginated_methods else {}
) )
status.start() status.start()
status.update(f"[dim]Getting {method_name} from {source}[/dim]...") status.update(f"[dim]Getting {method_name} from {source}[/dim]")
return method(**params) return method(**params)
def response_handler(self, data: t.Union[dict, list], data_type: str, source: str): def response_handler(self, data: t.Union[dict, list], data_type: str, source: str):
@@ -258,10 +263,10 @@ class BaseMenu:
source=source, source=source,
file_formats=file_formats, file_formats=file_formats,
) )
console.input(" Press [bold]ENTER[/bold] to continue …")
except KeyboardInterrupt: except KeyboardInterrupt:
console.print("\nExport cancelled") console.print("\nExport cancelled")
finally:
console.input(" Press [bold]ENTER[/bold] to continue ...")
def navigation_handler(self, option: str, callback: t.Callable, *callback_args): def navigation_handler(self, option: str, callback: t.Callable, *callback_args):
""" """
@@ -470,7 +475,7 @@ class Menus(BaseMenu):
# Execute search if it's a valid method # Execute search if it's a valid method
if option in self.search_methods: if option in self.search_methods:
with Status( with Status(
status=f"[dim]Initialising {option} search[/dim]...", console=console status=f"[dim]Initialising {option} search[/dim]", console=console
) as status: ) as status:
status.stop() status.stop()
params = prompts.pagination_params() params = prompts.pagination_params()
@@ -484,7 +489,7 @@ class Menus(BaseMenu):
) )
method = getattr(search, option) method = getattr(search, option)
status.update(f"[dim]Searching {option} for {query}[/dim]...") status.update(f"[dim]Searching {option} for {query}[/dim]")
data = method() data = method()
if data: if data:

4
src/octosuite/meta.py Normal file
View File

@@ -0,0 +1,4 @@
from importlib.metadata import version
__pkg__ = "octosuite"
__version__ = version(__pkg__)

46
uv.lock generated
View File

@@ -4,7 +4,7 @@ requires-python = ">=3.13"
[[package]] [[package]]
name = "black" name = "black"
version = "25.12.0" version = "26.1.0"
source = { registry = "https://pypi.org/simple" } source = { registry = "https://pypi.org/simple" }
dependencies = [ dependencies = [
{ name = "click" }, { name = "click" },
@@ -14,19 +14,19 @@ dependencies = [
{ name = "platformdirs" }, { name = "platformdirs" },
{ name = "pytokens" }, { name = "pytokens" },
] ]
sdist = { url = "https://files.pythonhosted.org/packages/c4/d9/07b458a3f1c525ac392b5edc6b191ff140b596f9d77092429417a54e249d/black-25.12.0.tar.gz", hash = "sha256:8d3dd9cea14bff7ddc0eb243c811cdb1a011ebb4800a5f0335a01a68654796a7", size = 659264, upload-time = "2025-12-08T01:40:52.501Z" } sdist = { url = "https://files.pythonhosted.org/packages/13/88/560b11e521c522440af991d46848a2bde64b5f7202ec14e1f46f9509d328/black-26.1.0.tar.gz", hash = "sha256:d294ac3340eef9c9eb5d29288e96dc719ff269a88e27b396340459dd85da4c58", size = 658785, upload-time = "2026-01-18T04:50:11.993Z" }
wheels = [ wheels = [
{ url = "https://files.pythonhosted.org/packages/c8/52/c551e36bc95495d2aa1a37d50566267aa47608c81a53f91daa809e03293f/black-25.12.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:a05ddeb656534c3e27a05a29196c962877c83fa5503db89e68857d1161ad08a5", size = 1923809, upload-time = "2025-12-08T01:46:55.126Z" }, { url = "https://files.pythonhosted.org/packages/79/04/fa2f4784f7237279332aa735cdfd5ae2e7730db0072fb2041dadda9ae551/black-26.1.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:ba1d768fbfb6930fc93b0ecc32a43d8861ded16f47a40f14afa9bb04ab93d304", size = 1877781, upload-time = "2026-01-18T04:59:39.054Z" },
{ url = "https://files.pythonhosted.org/packages/a0/f7/aac9b014140ee56d247e707af8db0aae2e9efc28d4a8aba92d0abd7ae9d1/black-25.12.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:9ec77439ef3e34896995503865a85732c94396edcc739f302c5673a2315e1e7f", size = 1742384, upload-time = "2025-12-08T01:49:37.022Z" }, { url = "https://files.pythonhosted.org/packages/cf/ad/5a131b01acc0e5336740a039628c0ab69d60cf09a2c87a4ec49f5826acda/black-26.1.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2b807c240b64609cb0e80d2200a35b23c7df82259f80bef1b2c96eb422b4aac9", size = 1699670, upload-time = "2026-01-18T04:59:41.005Z" },
{ url = "https://files.pythonhosted.org/packages/74/98/38aaa018b2ab06a863974c12b14a6266badc192b20603a81b738c47e902e/black-25.12.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0e509c858adf63aa61d908061b52e580c40eae0dfa72415fa47ac01b12e29baf", size = 1798761, upload-time = "2025-12-08T01:46:05.386Z" }, { url = "https://files.pythonhosted.org/packages/da/7c/b05f22964316a52ab6b4265bcd52c0ad2c30d7ca6bd3d0637e438fc32d6e/black-26.1.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1de0f7d01cc894066a1153b738145b194414cc6eeaad8ef4397ac9abacf40f6b", size = 1775212, upload-time = "2026-01-18T04:59:42.545Z" },
{ url = "https://files.pythonhosted.org/packages/16/3a/a8ac542125f61574a3f015b521ca83b47321ed19bb63fe6d7560f348bfe1/black-25.12.0-cp313-cp313-win_amd64.whl", hash = "sha256:252678f07f5bac4ff0d0e9b261fbb029fa530cfa206d0a636a34ab445ef8ca9d", size = 1429180, upload-time = "2025-12-08T01:45:34.903Z" }, { url = "https://files.pythonhosted.org/packages/a6/a3/e8d1526bea0446e040193185353920a9506eab60a7d8beb062029129c7d2/black-26.1.0-cp313-cp313-win_amd64.whl", hash = "sha256:91a68ae46bf07868963671e4d05611b179c2313301bd756a89ad4e3b3db2325b", size = 1409953, upload-time = "2026-01-18T04:59:44.357Z" },
{ url = "https://files.pythonhosted.org/packages/e6/2d/bdc466a3db9145e946762d52cd55b1385509d9f9004fec1c97bdc8debbfb/black-25.12.0-cp313-cp313-win_arm64.whl", hash = "sha256:bc5b1c09fe3c931ddd20ee548511c64ebf964ada7e6f0763d443947fd1c603ce", size = 1239350, upload-time = "2025-12-08T01:46:09.458Z" }, { url = "https://files.pythonhosted.org/packages/c7/5a/d62ebf4d8f5e3a1daa54adaab94c107b57be1b1a2f115a0249b41931e188/black-26.1.0-cp313-cp313-win_arm64.whl", hash = "sha256:be5e2fe860b9bd9edbf676d5b60a9282994c03fbbd40fe8f5e75d194f96064ca", size = 1217707, upload-time = "2026-01-18T04:59:45.719Z" },
{ url = "https://files.pythonhosted.org/packages/35/46/1d8f2542210c502e2ae1060b2e09e47af6a5e5963cb78e22ec1a11170b28/black-25.12.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:0a0953b134f9335c2434864a643c842c44fba562155c738a2a37a4d61f00cad5", size = 1917015, upload-time = "2025-12-08T01:53:27.987Z" }, { url = "https://files.pythonhosted.org/packages/6a/83/be35a175aacfce4b05584ac415fd317dd6c24e93a0af2dcedce0f686f5d8/black-26.1.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:9dc8c71656a79ca49b8d3e2ce8103210c9481c57798b48deeb3a8bb02db5f115", size = 1871864, upload-time = "2026-01-18T04:59:47.586Z" },
{ url = "https://files.pythonhosted.org/packages/41/37/68accadf977672beb8e2c64e080f568c74159c1aaa6414b4cd2aef2d7906/black-25.12.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:2355bbb6c3b76062870942d8cc450d4f8ac71f9c93c40122762c8784df49543f", size = 1741830, upload-time = "2025-12-08T01:54:36.861Z" }, { url = "https://files.pythonhosted.org/packages/a5/f5/d33696c099450b1274d925a42b7a030cd3ea1f56d72e5ca8bbed5f52759c/black-26.1.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:b22b3810451abe359a964cc88121d57f7bce482b53a066de0f1584988ca36e79", size = 1701009, upload-time = "2026-01-18T04:59:49.443Z" },
{ url = "https://files.pythonhosted.org/packages/ac/76/03608a9d8f0faad47a3af3a3c8c53af3367f6c0dd2d23a84710456c7ac56/black-25.12.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9678bd991cc793e81d19aeeae57966ee02909877cb65838ccffef24c3ebac08f", size = 1791450, upload-time = "2025-12-08T01:44:52.581Z" }, { url = "https://files.pythonhosted.org/packages/1b/87/670dd888c537acb53a863bc15abbd85b22b429237d9de1b77c0ed6b79c42/black-26.1.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:53c62883b3f999f14e5d30b5a79bd437236658ad45b2f853906c7cbe79de00af", size = 1767806, upload-time = "2026-01-18T04:59:50.769Z" },
{ url = "https://files.pythonhosted.org/packages/06/99/b2a4bd7dfaea7964974f947e1c76d6886d65fe5d24f687df2d85406b2609/black-25.12.0-cp314-cp314-win_amd64.whl", hash = "sha256:97596189949a8aad13ad12fcbb4ae89330039b96ad6742e6f6b45e75ad5cfd83", size = 1452042, upload-time = "2025-12-08T01:46:13.188Z" }, { url = "https://files.pythonhosted.org/packages/fe/9c/cd3deb79bfec5bcf30f9d2100ffeec63eecce826eb63e3961708b9431ff1/black-26.1.0-cp314-cp314-win_amd64.whl", hash = "sha256:f016baaadc423dc960cdddf9acae679e71ee02c4c341f78f3179d7e4819c095f", size = 1433217, upload-time = "2026-01-18T04:59:52.218Z" },
{ url = "https://files.pythonhosted.org/packages/b2/7c/d9825de75ae5dd7795d007681b752275ea85a1c5d83269b4b9c754c2aaab/black-25.12.0-cp314-cp314-win_arm64.whl", hash = "sha256:778285d9ea197f34704e3791ea9404cd6d07595745907dd2ce3da7a13627b29b", size = 1267446, upload-time = "2025-12-08T01:46:14.497Z" }, { url = "https://files.pythonhosted.org/packages/4e/29/f3be41a1cf502a283506f40f5d27203249d181f7a1a2abce1c6ce188035a/black-26.1.0-cp314-cp314-win_arm64.whl", hash = "sha256:66912475200b67ef5a0ab665011964bf924745103f51977a78b4fb92a9fc1bf0", size = 1245773, upload-time = "2026-01-18T04:59:54.457Z" },
{ url = "https://files.pythonhosted.org/packages/68/11/21331aed19145a952ad28fca2756a1433ee9308079bd03bd898e903a2e53/black-25.12.0-py3-none-any.whl", hash = "sha256:48ceb36c16dbc84062740049eef990bb2ce07598272e673c17d1a7720c71c828", size = 206191, upload-time = "2025-12-08T01:40:50.963Z" }, { url = "https://files.pythonhosted.org/packages/e4/3d/51bdb3ecbfadfaf825ec0c75e1de6077422b4afa2091c6c9ba34fbfc0c2d/black-26.1.0-py3-none-any.whl", hash = "sha256:1054e8e47ebd686e078c0bb0eaf31e6ce69c966058d122f2c0c950311f9f3ede", size = 204010, upload-time = "2026-01-18T04:50:09.978Z" },
] ]
[[package]] [[package]]
@@ -141,8 +141,8 @@ wheels = [
[[package]] [[package]]
name = "octosuite" name = "octosuite"
version = "4.0.0" version = "5.1.0"
source = { virtual = "." } source = { editable = "." }
dependencies = [ dependencies = [
{ name = "pyfiglet" }, { name = "pyfiglet" },
{ name = "questionary" }, { name = "questionary" },
@@ -157,10 +157,10 @@ dev = [
[package.metadata] [package.metadata]
requires-dist = [ requires-dist = [
{ name = "black", marker = "extra == 'dev'", specifier = ">=25.12.0" }, { name = "black", marker = "extra == 'dev'", specifier = ">=26.1.0" },
{ name = "pyfiglet", specifier = ">=1.0.4" }, { name = "pyfiglet", specifier = ">=1.0.4" },
{ name = "questionary", specifier = ">=2.1.1" }, { 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" }, { name = "update-checker", specifier = ">=0.18.0" },
] ]
provides-extras = ["dev"] provides-extras = ["dev"]
@@ -176,11 +176,11 @@ wheels = [
[[package]] [[package]]
name = "pathspec" name = "pathspec"
version = "0.12.1" version = "1.0.3"
source = { registry = "https://pypi.org/simple" } source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/ca/bc/f35b8446f4531a7cb215605d100cd88b7ac6f44ab3fc94870c120ab3adbf/pathspec-0.12.1.tar.gz", hash = "sha256:a482d51503a1ab33b1c67a6c3813a26953dbdc71c31dacaef9a838c4e29f5712", size = 51043, upload-time = "2023-12-10T22:30:45Z" } sdist = { url = "https://files.pythonhosted.org/packages/4c/b2/bb8e495d5262bfec41ab5cb18f522f1012933347fb5d9e62452d446baca2/pathspec-1.0.3.tar.gz", hash = "sha256:bac5cf97ae2c2876e2d25ebb15078eb04d76e4b98921ee31c6f85ade8b59444d", size = 130841, upload-time = "2026-01-09T15:46:46.009Z" }
wheels = [ wheels = [
{ url = "https://files.pythonhosted.org/packages/cc/20/ff623b09d963f88bfde16306a54e12ee5ea43e9b597108672ff3a408aad6/pathspec-0.12.1-py3-none-any.whl", hash = "sha256:a0d503e138a4c123b27490a4f7beda6a01c6f288df0e4a8b79c7eb0dc7b4cc08", size = 31191, upload-time = "2023-12-10T22:30:43.14Z" }, { url = "https://files.pythonhosted.org/packages/32/2b/121e912bd60eebd623f873fd090de0e84f322972ab25a7f9044c056804ed/pathspec-1.0.3-py3-none-any.whl", hash = "sha256:e80767021c1cc524aa3fb14bedda9c34406591343cc42797b386ce7b9354fb6c", size = 55021, upload-time = "2026-01-09T15:46:44.652Z" },
] ]
[[package]] [[package]]
@@ -260,15 +260,15 @@ wheels = [
[[package]] [[package]]
name = "rich" name = "rich"
version = "14.2.0" version = "14.3.3"
source = { registry = "https://pypi.org/simple" } source = { registry = "https://pypi.org/simple" }
dependencies = [ dependencies = [
{ name = "markdown-it-py" }, { name = "markdown-it-py" },
{ name = "pygments" }, { 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 = [ 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]] [[package]]