From b0aef9e2041952c29e20c7a6e3d72d9d65ce690a Mon Sep 17 00:00:00 2001 From: Ritchie Mwewa <74001397+rly0nheart@users.noreply.github.com> Date: Sat, 3 Jan 2026 03:44:28 +0200 Subject: [PATCH] 4.0/beta --- Makefile | 33 ++ octosuite/__init__.py | 223 ++++++++ octosuite/core/api.py | 37 -- octosuite/core/github.py | 77 +++ octosuite/core/models.py | 131 +++-- octosuite/main.py | 6 +- octosuite/tui/dialogs.py | 44 ++ octosuite/tui/menus.py | 1157 ++++++++++++++++++++++---------------- octosuite/tui/prompts.py | 36 +- pyproject.toml | 8 +- uv.lock | 197 ++++--- 11 files changed, 1267 insertions(+), 682 deletions(-) create mode 100644 Makefile delete mode 100644 octosuite/core/api.py create mode 100644 octosuite/core/github.py create mode 100644 octosuite/tui/dialogs.py diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..6bd4760 --- /dev/null +++ b/Makefile @@ -0,0 +1,33 @@ +.PHONY: help install dev run test lint format clean sync lock update + +help: + @echo "Available commands:" + @echo " make install - Install dependencies" + @echo " make dev - Install dev dependencies" + @echo " make run - Run the application" + @echo " make format - Format code" + @echo " make sync - Sync dependencies with lock file" + @echo " make lock - Update lock file" + @echo " make update - Update dependencies" + +install: + @uv pip install . + +dev: + @uv pip install .[dev] + +run: install + @uv run octosuite + +format: + @uv run black . + +sync: + @uv sync + +lock: + @uv lock + +update: + @uv lock --upgrade + @uv sync \ No newline at end of file diff --git a/octosuite/__init__.py b/octosuite/__init__.py index e69de29..82a01c1 100644 --- a/octosuite/__init__.py +++ b/octosuite/__init__.py @@ -0,0 +1,223 @@ +import csv +import json +import os +import subprocess +import typing as t +from datetime import datetime +from pathlib import Path + +import pyfiglet +from prompt_toolkit.shortcuts import message_dialog +from rich.console import Console +from rich.text import Text +from rich.tree import Tree +from update_checker import UpdateChecker + +__pkg__ = "octosuite" +__version__ = "4.0.0" +__author__ = "Ritchie Mwewa" + +__all__ = [ + "console", + "__pkg__", + "__author__", + "__version__", + "preview_response", + "export_response", + "check_updates", + "clear_screen", + "text_banner", + "set_menu_title", +] + +console = Console(log_time=False) + + +def preview_response(data: t.Union[dict, list], source: str, _type: str): + if isinstance(data, dict): + tree = Tree( + label=f"\n[bold]{data.get('name') or data.get('login') or data.get('id') or 'Data'}[/bold]", + guide_style="#444444", + highlight=True, + ) + fill_tree(tree=tree, data=data) + console.print(tree) + print() + + elif isinstance(data, list): + preview_data = data[:5] + tree = Tree( + label=f"\nPreview: First {len(preview_data)} of {len(data)} {_type} from {source}", + guide_style="#444444", + highlight=True, + ) + + for item in preview_data: + if isinstance(item, dict): + branch_label = ( + item.get("full_name") + or item.get("name") + or item.get("login") + or item.get("id") + or "Item" + ) + branch = tree.add(label=f"[bold]{branch_label}[/bold]", highlight=True) + fill_tree(tree=branch, data=item) + + console.print(tree) + print() + else: + console.print(data) + + +def export_response(data, data_type, source, file_formats, output_dir="../exports"): + """Export data to selected formats using built-in libraries.""" + # Create output directory if it doesn't exist + output_dir = Path(output_dir) + output_dir.mkdir(exist_ok=True) + + # Generate filename with timestamp + timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") + safe_source = source.replace("/", "_") + filename = f"{safe_source}_{data_type}_{timestamp}" + + # Normalize data to list of dicts + if isinstance(data, dict): + data_list = [data] + else: + data_list = data + + # Export to all selected formats + exported_files = [] + for file_format in file_formats: + filepath = output_dir / f"{filename}.{file_format}" + + if file_format == "json": + with open(filepath, "w", encoding="utf-8") as f: + json.dump(data_list, f, indent=2, ensure_ascii=False) + + elif file_format == "csv": + if data_list: + # Get all unique keys from all dicts + keys = set() + for item in data_list: + if isinstance(item, dict): + keys.update(item.keys()) + keys = sorted(keys) + + with open(filepath, "w", newline="", encoding="utf-8") as f: + writer = csv.DictWriter(f, fieldnames=keys) + writer.writeheader() + for item in data_list: + if isinstance(item, dict): + writer.writerow(item) + + elif file_format == "html": + with open(filepath, "w", encoding="utf-8") as file: + file.write("\n\n\n") + file.write('\n') + file.write("\n") + file.write("\n\n") + file.write('\n') + + if data_list: + # Get all unique keys + keys = set() + for item in data_list: + if isinstance(item, dict): + keys.update(item.keys()) + keys = sorted(keys) + + # Write header + file.write("\n\n") + for key in keys: + file.write(f"\n") + file.write("\n\n") + + # Write rows + file.write("\n") + for item in data_list: + if isinstance(item, dict): + file.write("\n") + for key in keys: + value = item.get(key, "") + file.write(f"\n") + file.write("\n") + file.write("\n") + + file.write("
{key}
{value}
\n\n") + + exported_files.append(str(filepath)) + + # Show success message + console.print("\n[green]✓ Export successful![/green]") + for filepath in exported_files: + console.print(f" • {filepath}") + + return exported_files + + +def fill_tree(tree: Tree, data: t.Union[dict, list]) -> Tree: + if isinstance(data, dict): + for key, value in data.items(): + if isinstance(value, dict) or isinstance(value, list): + branch = tree.add(f"{key}") + fill_tree(tree=branch, data=value) + else: + tree.add(f"[dim]{key.replace('_', ' ').title()}[/dim]: {value}") + + elif isinstance(data, list): + for item in data: + if isinstance(item, dict) or isinstance(item, list): + fill_tree(tree=tree, data=item) + else: + tree.add(str(item)) + + else: + tree.add(str(data)) + + return tree + + +def check_updates(): + checker = UpdateChecker() + result = checker.check(__pkg__, __version__) + if result: + message_dialog(title="Update Available", text=result).run() + else: + message_dialog( + title="Up to Date", + text=f"You're running the current version, {__version__}", + ).run() + + +def clear_screen(): + """Clear the terminal screen.""" + subprocess.run(["cls" if os.name == "nt" else "clear"]) + + +def text_banner(text: str): + clear_screen() + + ascii_text = pyfiglet.figlet_format(text=text, font="chunky") + banner_text = Text(ascii_text) + + length = len(ascii_text) + for i in range(length): + ratio = i / max(length - 1, 1) + r = int(255 - 127 * ratio) + g = int(200 - 200 * ratio) + b = 255 + + banner_text.stylize(f"rgb({r},{g},{b})", i, i + 1) + console.print(banner_text) + + +def set_menu_title(menu_type: t.Literal["home", "user", "org", "repo", "search"]): + title: str = __pkg__.title() + title += f" | {menu_type.title()}" + console.set_window_title(title=title) diff --git a/octosuite/core/api.py b/octosuite/core/api.py deleted file mode 100644 index 30bdfc7..0000000 --- a/octosuite/core/api.py +++ /dev/null @@ -1,37 +0,0 @@ -import re -import typing as t - -import requests - -BASE_URL = "https://api.github.com" - - -class Api: - def __init__(self): ... - - def get(self, url: str, params: t.Optional[dict] = None) -> t.Union[dict, list]: - response = requests.get(url, params=params) - response.raise_for_status() - return self._sanitise_response(response=response.json()) - - def _sanitise_response(self, response: t.Union[dict, list]) -> t.Union[dict, list]: - pattern = re.compile(r"https://api\.github\.com") - - if isinstance(response, list): - return [self._sanitise_response(response=item) for item in response] - - if isinstance(response, dict): - keys_to_remove = [ - key - for key, value in response.items() - if (isinstance(value, str) and pattern.search(value)) or value is None - ] - for key in keys_to_remove: - response.pop(key) - - # Recursively clean nested dicts/lists - for key, value in response.items(): - if isinstance(value, (dict, list)): - response[key] = self._sanitise_response(response=value) - - return response diff --git a/octosuite/core/github.py b/octosuite/core/github.py new file mode 100644 index 0000000..4965ed6 --- /dev/null +++ b/octosuite/core/github.py @@ -0,0 +1,77 @@ +import re +import sys +import typing as t + +import requests +from requests import Response + +from .. import __version__ + +BASE_URL = "https://api.github.com" + + +class GitHub: + def __init__( + self, + user_agent: str = ( + f"octosuite/{__version__} " + f"(Python {sys.version.split()[0]}; " + f"https://github.com/bellingcat/octosuite) requests/{requests.__version__}" + ), + ): + self.user_agent = user_agent + + def get( + self, url: str, params: t.Optional[dict] = None, return_response: bool = False + ) -> t.Union[dict, list, Response]: + response = requests.get( + url=url, params=params, headers={"User-Agent": self.user_agent} + ) + + if return_response: + return response + + if response.status_code == 200: + return self._sanitise_response(response=response.json()) + return [] + + def is_valid_entity( + self, entity_type: t.Literal["user", "org", "repo"], **kwargs + ) -> bool: + """Validate if a GitHub entity exists.""" + try: + if entity_type == "user": + url = f"https://api.github.com/users/{kwargs.get('username')}" + elif entity_type == "org": + url = f"https://api.github.com/orgs/{kwargs.get('username')}" + elif entity_type == "repo": + url = f"https://api.github.com/repos/{kwargs.get('repo_owner')}/{kwargs.get('repo_name')}" + else: + return True + + response = self.get(url=url, return_response=True) + return response.status_code == 200 + except requests.RequestException: + return False + + def _sanitise_response(self, response: t.Union[dict, list]) -> t.Union[dict, list]: + pattern = re.compile(r"https://api\.github\.com") + + if isinstance(response, list): + return [self._sanitise_response(response=item) for item in response] + + if isinstance(response, dict): + keys_to_remove = [ + key + for key, value in response.items() + if (isinstance(value, str) and pattern.search(value)) or value is None + ] + for key in keys_to_remove: + response.pop(key) + + # Recursively clean nested dicts/lists + for key, value in response.items(): + if isinstance(value, (dict, list)): + response[key] = self._sanitise_response(response=value) + + return response diff --git a/octosuite/core/models.py b/octosuite/core/models.py index 520af69..93fff8b 100644 --- a/octosuite/core/models.py +++ b/octosuite/core/models.py @@ -1,187 +1,238 @@ -from .api import Api, BASE_URL +from requests import exceptions + +from .github import GitHub, BASE_URL + +github = GitHub() + +__all__ = ["User", "Org", "Repo", "Search"] -api = Api() +class GitHubEntity: + """Base class for GitHub entities with common functionality.""" + + def __init__(self, source: str): + self.endpoint = None + self.source = source + + def exists(self) -> bool: + """Check if the entity exists on GitHub.""" + try: + response = github.get(url=self.endpoint, return_response=True) + return response.status_code == 200 + except exceptions.RequestException as err: + return False -class User: +class User(GitHubEntity): def __init__(self, name: str): - + super().__init__(source=name) self.name = name self.endpoint = f"{BASE_URL}/users/{name}" def profile(self) -> dict: - profile = api.get(url=self.endpoint) + profile = github.get(url=self.endpoint) return profile def repos(self, page: int, per_page: int) -> list: params = {"page": page, "per_page": per_page} - repos = api.get(url=f"{self.endpoint}/repos", params=params) + repos = github.get(url=f"{self.endpoint}/repos", params=params) return repos def subscriptions(self, page: int, per_page: int) -> list: params = {"page": page, "per_page": per_page} - subscriptions = api.get(url=f"{self.endpoint}/subscriptions", params=params) + subscriptions = github.get(url=f"{self.endpoint}/subscriptions", params=params) return subscriptions def starred(self, page: int, per_page: int) -> list: params = {"page": page, "per_page": per_page} - starred = api.get(url=f"{self.endpoint}/starred", params=params) + starred = github.get(url=f"{self.endpoint}/starred", params=params) return starred def followers(self, page: int, per_page: int) -> list: params = {"page": page, "per_page": per_page} - users = api.get(url=f"{self.endpoint}/followers", params=params) + users = github.get(url=f"{self.endpoint}/followers", params=params) return users def following(self, page: int, per_page: int) -> list: params = {"page": page, "per_page": per_page} - users = api.get(url=f"{self.endpoint}/following", params=params) + users = github.get(url=f"{self.endpoint}/following", params=params) return users def follows(self, user: str) -> bool: ... def orgs(self, page: int, per_page: int) -> list: params = {"page": page, "per_page": per_page} - orgs = api.get(url=f"{self.endpoint}/orgs", params=params) + orgs = github.get(url=f"{self.endpoint}/orgs", params=params) return orgs def gists(self, page: int, per_page: int) -> list: params = {"page": page, "per_page": per_page} - gists = api.get(url=f"{self.endpoint}/gists", params=params) + gists = github.get(url=f"{self.endpoint}/gists", params=params) return gists def events(self, page: int, per_page: int) -> list: params = {"page": page, "per_page": per_page} - events = api.get(url=f"{self.endpoint}/events", params=params) + events = github.get(url=f"{self.endpoint}/events", params=params) return events def received_events(self, page: int, per_page: int) -> list: params = {"page": page, "per_page": per_page} - received_events = api.get(url=f"{self.endpoint}/received_events", params=params) + received_events = github.get( + url=f"{self.endpoint}/received_events", params=params + ) return received_events -class Org: +class Org(GitHubEntity): def __init__(self, name: str): - + super().__init__(source=name) self.name = name self.endpoint = f"{BASE_URL}/orgs/{name}" def profile(self) -> dict: - profile = api.get(url=self.endpoint) + profile = github.get(url=self.endpoint) return profile def repos(self, page: int, per_page: int) -> list: params = {"page": page, "per_page": per_page} - repos = api.get(url=f"{self.endpoint}/repos", params=params) + repos = github.get(url=f"{self.endpoint}/repos", params=params) return repos def events(self, page: int, per_page: int) -> list: params = {"page": page, "per_page": per_page} - events = api.get(url=f"{self.endpoint}/events", params=params) + events = github.get(url=f"{self.endpoint}/events", params=params) return events def hooks(self, page: int, per_page: int) -> list: params = {"page": page, "per_page": per_page} - hooks = api.get(url=f"{self.endpoint}/hooks", params=params) + hooks = github.get(url=f"{self.endpoint}/hooks", params=params) return hooks def issues(self, page: int, per_page: int) -> list: params = {"page": page, "per_page": per_page} - issues = api.get(url=f"{self.endpoint}/issues", params=params) + issues = github.get(url=f"{self.endpoint}/issues", params=params) return issues def members(self, page: int, per_page: int) -> list: params = {"page": page, "per_page": per_page} - members = api.get(url=f"{self.endpoint}/members", params=params) + members = github.get(url=f"{self.endpoint}/members", params=params) return members -class Repo: +class Repo(GitHubEntity): def __init__(self, name: str, owner: str): + super().__init__(source=f"{owner}/{name}") self.name = name self.owner = owner self.endpoint = f"{BASE_URL}/repos/{owner}/{name}" def profile(self) -> dict: - profile = api.get(url=self.endpoint) + profile = github.get(url=self.endpoint) return profile def forks(self, page: int, per_page: int) -> list: params = {"page": page, "per_page": per_page} - forks = api.get(url=f"{self.endpoint}/forks", params=params) + forks = github.get(url=f"{self.endpoint}/forks", params=params) return forks def issue_events(self, page: int, per_page: int) -> list: params = {"page": page, "per_page": per_page} - issue_events = api.get(url=f"{self.endpoint}/issue_events", params=params) + issue_events = github.get(url=f"{self.endpoint}/issue_events", params=params) return issue_events def events(self, page: int, per_page: int) -> list: params = {"page": page, "per_page": per_page} - events = api.get(url=f"{self.endpoint}/events", params=params) + events = github.get(url=f"{self.endpoint}/events", params=params) return events def assignees(self, page: int, per_page: int) -> list: params = {"page": page, "per_page": per_page} - assignees = api.get(url=f"{self.endpoint}/assignees", params=params) + assignees = github.get(url=f"{self.endpoint}/assignees", params=params) return assignees def branches(self, page: int, per_page: int) -> list: params = {"page": page, "per_page": per_page} - branches = api.get(url=f"{self.endpoint}/branches", params=params) + branches = github.get(url=f"{self.endpoint}/branches", params=params) return branches def tags(self, page: int, per_page: int) -> list: params = {"page": page, "per_page": per_page} - tags = api.get(url=f"{self.endpoint}/tags", params=params) + tags = github.get(url=f"{self.endpoint}/tags", params=params) return tags def languages(self) -> dict: - languages = api.get(url=f"{self.endpoint}/languages") + languages = github.get(url=f"{self.endpoint}/languages") return languages def stargazers(self, page: int, per_page: int) -> list: params = {"page": page, "per_page": per_page} - stargazers = api.get(url=f"{self.endpoint}/stargazers", params=params) + stargazers = github.get(url=f"{self.endpoint}/stargazers", params=params) return stargazers def subscribers(self, page: int, per_page: int) -> list: params = {"page": page, "per_page": per_page} - subscribers = api.get(url=f"{self.endpoint}/subscribers", params=params) + subscribers = github.get(url=f"{self.endpoint}/subscribers", params=params) return subscribers def commits(self, page: int, per_page: int) -> list: params = {"page": page, "per_page": per_page} - commits = api.get(url=f"{self.endpoint}/commits", params=params) + commits = github.get(url=f"{self.endpoint}/commits", params=params) return commits def comments(self, page: int, per_page: int) -> list: params = {"page": page, "per_page": per_page} - comments = api.get(url=f"{self.endpoint}/comments", params=params) + comments = github.get(url=f"{self.endpoint}/comments", params=params) return comments def contents(self, path: str) -> list: - contents = api.get(url=f"{self.endpoint}/contents/{path}") + contents = github.get(url=f"{self.endpoint}/contents/{path}") return contents def issues(self, page: int, per_page: int) -> list: params = {"page": page, "per_page": per_page} - issues = api.get(url=f"{self.endpoint}/issues", params=params) + issues = github.get(url=f"{self.endpoint}/issues", params=params) return issues def releases(self, page: int, per_page: int) -> list: params = {"page": page, "per_page": per_page} - releases = api.get(url=f"{self.endpoint}/releases", params=params) + releases = github.get(url=f"{self.endpoint}/releases", params=params) return releases def deployments(self, page: int, per_page: int) -> list: params = {"page": page, "per_page": per_page} - deployments = api.get(url=f"{self.endpoint}/deployments", params=params) + deployments = github.get(url=f"{self.endpoint}/deployments", params=params) return deployments def labels(self, page: int, per_page: int) -> list: params = {"page": page, "per_page": per_page} - labels = api.get(url=f"{self.endpoint}/labels", params=params) + labels = github.get(url=f"{self.endpoint}/labels", params=params) return labels + + +class Search: + def __init__(self, query: str, page: int, per_page: int): + self.query = query + self.page = page + self.per_page = per_page + self.endpoint = f"{BASE_URL}/search" + self.params = {"q": query, "page": page, "per_page": per_page} + + def repos(self) -> list: + repos = github.get(url=f"{self.endpoint}/repositories", params=self.params) + return repos + + def users(self) -> list: + users = github.get(url=f"{self.endpoint}/users", params=self.params) + return users + + def commits(self) -> list: + commits = github.get(url=f"{self.endpoint}/commits", params=self.params) + return commits + + def issues(self) -> list: + issues = github.get(url=f"{self.endpoint}/issues", params=self.params) + return issues + + def topics(self) -> list: + topics = github.get(url=f"{self.endpoint}/topics", params=self.params) + return topics diff --git a/octosuite/main.py b/octosuite/main.py index ddabbee..bf44e68 100644 --- a/octosuite/main.py +++ b/octosuite/main.py @@ -1,13 +1,13 @@ import sys +from . import console, __pkg__, __version__ from .tui.menus import Menus -from .tui.prompts import Prompts def start(): try: - prompts = Prompts() - menu = Menus(prompts=prompts) + console.set_window_title(title=f"{__pkg__.title()} v{__version__}") + menu = Menus() menu.main() except KeyboardInterrupt: sys.exit() diff --git a/octosuite/tui/dialogs.py b/octosuite/tui/dialogs.py new file mode 100644 index 0000000..aab3e83 --- /dev/null +++ b/octosuite/tui/dialogs.py @@ -0,0 +1,44 @@ +from datetime import datetime + +from prompt_toolkit.shortcuts import button_dialog, message_dialog + +LICENSE_NOTICE = f"""Copyright (c) {datetime.now().year} Bellingcat + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE.""" + + +class Dialogs: + def __init__(self): + ... + + @staticmethod + def quit() -> bool: + try: + result = button_dialog( + title="Quit", + text="This will close the session, continue?", + buttons=[("Yes", True), ("No", False)], + ).run() + return result if result is not None else False + except KeyboardInterrupt: + return True + + @staticmethod + def license(): + message_dialog(title="MIT License", text=LICENSE_NOTICE).run() diff --git a/octosuite/tui/menus.py b/octosuite/tui/menus.py index af0a072..d121508 100644 --- a/octosuite/tui/menus.py +++ b/octosuite/tui/menus.py @@ -1,17 +1,15 @@ -import os -import subprocess import sys import typing as t -from datetime import datetime -from pathlib import Path -import pyfiglet import questionary as q from questionary import Style -from rich.console import Console +from rich.status import Status +from .dialogs import Dialogs from .prompts import Prompts -from ..core.models import User, Repo, Org +from .. import check_updates, preview_response, export_response, set_menu_title +from .. import console, clear_screen, text_banner +from ..core.models import User, Repo, Org, Search CUSTOM_STYLE = Style( [ @@ -20,24 +18,19 @@ CUSTOM_STYLE = Style( ] ) -INSTRUCTIONS = "[Move] ⮃ [ENTER] ⮠" +INSTRUCTIONS = "↑↓ [move] • ⮠ [select]" +EXPORT_INSTRUCTIONS = "↑↓ [move] • ⮠ [confirm] • spacebar [check]" +POINTER: str = "🖝 " -POINTER = "🖛 " - -console = Console() +__all__ = ["Menus"] -def clear_screen(): - """Clear the terminal screen.""" - subprocess.run(["cls" if os.name == "nt" else "clear"]) - - -def banner(text: str): - console.print(pyfiglet.figlet_format(text=text, font="chunky")) +dialogs = Dialogs() +prompts = Prompts() class Menus: - def __init__(self, prompts: Prompts): + def __init__(self): # Define which methods require pagination self.paginated_methods = { "repos", @@ -65,108 +58,42 @@ class Menus: "hooks", "members", } - self.prompts = prompts # Methods that don't need pagination self.non_paginated_methods = {"profile", "languages"} + # Search methods (all require pagination) + self.search_methods = {"repos", "users", "commits", "issues", "topics"} + self.mode_handlers = { "user": self.user, "repo": self.repo, "org": self.org, - "search": lambda: ( - print("Search functionality not yet implemented"), - self.main(), - ), + "search": self.search, } - def main(self): - """Main menu to select mode.""" - clear_screen() - try: - banner(text="octosuite") - - mode = q.select( - "Select a mode to begin with", - choices=[ - q.Choice( - title="User", - value="user", - description="data about a user", - shortcut_key="u", - ), - q.Choice( - title="Repo", - value="repo", - description="data about a repository", - shortcut_key="r", - ), - q.Choice( - title="Org", - value="org", - description="data about an organisation", - shortcut_key="o", - ), - q.Choice( - title="Search", - value="search", - description="Search GitHub", - shortcut_key="s", - ), - q.Choice( - title="Check for updates", - value="check_updates", - description="Check for updates", - shortcut_key="c", - ), - q.Choice( - title="Quit", - value="quit", - description="Close this session", - shortcut_key="q", - ), - ], - style=CUSTOM_STYLE, - instruction=INSTRUCTIONS, - pointer=POINTER, - use_shortcuts=True, - ).ask() - - if mode == "quit": - if self.prompts.quit(): - sys.exit() - else: - self.main() - else: - handler = self.mode_handlers.get(mode) - if handler: - handler() - else: - self.main() - except KeyboardInterrupt: - sys.exit() - - def _execute_action(self, instance, method_name, target: str): + def _execute_selection(self, status: Status, **kwargs): """Execute a method on an instance, prompting for pagination if needed.""" - method = getattr(instance, method_name) + instance = kwargs.get("instance") + method_name = kwargs.get("method_name") + source = kwargs.get("source") + method = getattr(instance, method_name) + status.stop() # pagination params if needed params = ( - self.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.update(f"[dim]Getting {method_name} from {source}...[/dim]") + return method(**params) - # Execute with status spinner - with console.status(f"Fetching {method_name} for {target}..."): - return method(**params) - - def _handle_navigation(self, option, callback, *callback_args): + def _navigation(self, option, callback, *callback_args): """Handle navigation options (back, quit, change settings).""" navigation_handlers = { "back": lambda: self.main(), "quit": lambda: ( - sys.exit() if self.prompts.quit() else callback(*callback_args) + sys.exit() if dialogs.quit() else callback(*callback_args) ), } @@ -176,72 +103,49 @@ class Menus: return True return False - @staticmethod - def _preview_data(data: t.Union[dict, list]): - """Preview data as formatted JSON.""" - import json - from rich.panel import Panel - from rich.syntax import Syntax - - if isinstance(data, dict): - # Show the entire dict as JSON - json_str = json.dumps(data, indent=2) - syntax = Syntax(json_str, "json", line_numbers=False) - console.print( - Panel( - renderable=syntax, - border_style="#444444", - ) - ) - - elif isinstance(data, list): - if not data: - console.print("[yellow]No data to preview[/yellow]") - return - - console.print(f"Total items: {len(data)}") - console.print(f"Showing: first 5 items") - - # Show first 5 items as JSON - preview_data = data[:5] - json_str = json.dumps(preview_data, indent=2) - syntax = Syntax(json_str, "json", line_numbers=False) - console.print( - Panel( - renderable=syntax, - border_style="#444444", - ) - ) - - else: - console.print("[yellow]No data to preview[/yellow]") - return - - console.print() - - def export(self, data: t.Union[dict, list], data_type: str, target: str) -> None: + def response_handling(self, data: t.Union[dict, list], data_type: str, source: str): """Export data to file in user-selected format(s).""" - if not data: - console.print(f"No data found for {target}") - return + preview_response(data=data, source=source, _type=data_type) - # Show what was found - if isinstance(data, list): - console.print(f"\n✓ Found {len(data)} valid {data_type} for {target}") - else: - console.print(f"\n✓ Found valid {data_type} data for {target}") - - # Ask for export formats (multi-select) try: - self._preview_data(data=data) + export_choice = q.select( + "What would you like to do?", + choices=[ + q.Choice( + title="Export", + value="export", + description="Export the data", + shortcut_key="e", + ), + q.Choice( + title="Skip", + value="skip", + description="Do nothing, and go back to previous menu", + shortcut_key="x", + ), + q.Choice( + title="Quit", + value="quit", + description="Close this session", + ), + ], + pointer=POINTER, + style=CUSTOM_STYLE, + instruction=INSTRUCTIONS, + ).ask() - if not q.confirm( - "Would you like to export this data?", - default=True, - ).ask(): - console.print("[yellow]Skipping export.[/yellow]") + if export_choice == "skip": return + if export_choice == "quit": + if dialogs.quit(): + sys.exit() + else: + self.response_handling( + data=data, data_type=data_type, source=source + ) + return + file_formats = q.checkbox( "Select export format(s)", choices=[ @@ -261,382 +165,647 @@ class Menus: description="Export as HTML table", ), ], - style=CUSTOM_STYLE, - instruction="[SPACE] ☑ [ENTER] ⮠ [CTRL+C] Skip", pointer=POINTER, + style=CUSTOM_STYLE, + instruction=EXPORT_INSTRUCTIONS, validate=lambda x: len(x) > 0 or "Please select at least one format", ).ask() - if not file_formats: - console.print("[yellow]No formats selected. Skipping export.[/yellow]") - return - - import pandas as pd - - # Create output directory if it doesn't exist - output_dir = Path("../exports") - output_dir.mkdir(exist_ok=True) - - # Generate filename with timestamp - timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") - safe_target = target.replace("/", "_") - filename = f"{safe_target}_{data_type}_{timestamp}" - - # Convert data to DataFrame - if isinstance(data, dict): - # For dict data, convert to a single-row DataFrame - df = pd.DataFrame([data]) - else: - # For list data - df = pd.DataFrame(data) - - # Export to all selected formats - exported_files = [] - for file_format in file_formats: - filepath = output_dir / f"{filename}.{file_format}" - - if file_format == "json": - df.to_json(filepath, orient="records", indent=2) - elif file_format == "csv": - df.to_csv(filepath, index=False) - elif file_format == "html": - df.to_html(filepath, border=1, classes="table table-striped") - - exported_files.append(str(filepath)) - - # Show success message - console.print("\n[green]✓ Export successful![/green]") - for filepath in exported_files: - console.print(f" • {filepath}") + export_response( + data=data, data_type=data_type, source=source, file_formats=file_formats + ) except KeyboardInterrupt: print("\nExport cancelled") - def user(self, username: t.Optional[str] = None): - """User menu for querying user data.""" + def main(self): + """Main menu to select mode.""" + set_menu_title(menu_type="home") clear_screen() try: - if username is None: - banner(text="user") - username = self.prompts.prompt( - message="GitHub Username", instruction="e.g., octocat" - ) + text_banner(text="octosuite") - clear_screen() - banner(text=username) - - option = q.select( - "Select an action", + action = q.select( + "What action would you like to perform?", choices=[ q.Choice( - title="Profile", value="profile", description="Profile data" + title="User", + value="user", + description="data about a user", ), q.Choice( - title="Repositories", - value="repos", - description="Public repositories", + title="Repo", + value="repo", + description="data about a repository", ), q.Choice( - title="Subscriptions", - value="subscriptions", - description="Subscribed repositories", + title="Org", + value="org", + description="data about an organisation", ), q.Choice( - title="Starred", - value="starred", - description="Starred repositories", + title="Search", + value="search", + description="Search GitHub", ), q.Choice( - title="Followers", - value="followers", - description="Accounts that follow this user", + title="Updates", + value="updates", + description="Check for updates", ), q.Choice( - title="Following", - value="following", - description="Accounts that this user follows", - ), - q.Choice( - title="Organisations", - value="orgs", - description="This user's organisations", - ), - q.Choice( - title="Gists", - value="gists", - description="Gists (code snippets/files)", - ), - q.Choice(title="events", value="events", description="Events"), - q.Choice( - title="Received events", - value="received_events", - description="This user's received events", - ), - q.Choice( - title="Change username", - value="change_username", - description="Query a different user", - shortcut_key="u", - ), - q.Choice( - title="Back", - value="back", - description="Go back to previous menu", - shortcut_key="b", + title="License", + value="license", + description="Read license notice, and copyright information", ), q.Choice( title="Quit", value="quit", description="Close this session", - shortcut_key="q", ), ], + pointer=POINTER, style=CUSTOM_STYLE, instruction=INSTRUCTIONS, - pointer=POINTER, - use_shortcuts=True, ).ask() - # Handle navigation - if self._handle_navigation(option, self.user, username): - return - - # Handle change username - if option == "change_username": - self.user() - return - - # Execute action if it's a valid method - valid_methods = self.paginated_methods | self.non_paginated_methods - if option in valid_methods: - user = User(name=username) - data = self._execute_action(user, option, username) - self.export(data=data, data_type=option, target=username) - - self.user(username=username) + if action == "quit": + if dialogs.quit(): + sys.exit() + else: + self.main() + elif action == "license": + dialogs.license() + self.main() + elif action == "updates": + check_updates() + self.main() + elif action is None: + sys.exit() + else: + handler = self.mode_handlers.get(action) + if handler: + handler() + else: + self.main() except KeyboardInterrupt: - print("\n\nReturning to main menu...") + sys.exit() + + def search(self, query: t.Optional[str] = None): + """Search menu for querying GitHub.""" + set_menu_title(menu_type="search") + clear_screen() + if query is None: + text_banner(text="Search") + query = prompts.prompt( + message="Search Query", + instruction="e.g., machine learning", + style=CUSTOM_STYLE, + ) + + clear_screen() + text_banner(text=query) + + option = q.select( + "What would you like to do/search?", + choices=[ + q.Choice( + title="Change Query", + value="change_query", + description="Search with a different query", + shortcut_key="c", + ), + q.Choice( + title="Repositories", + value="repos", + description="Search for repositories", + ), + q.Choice( + title="Users", + value="users", + description="Search for users", + ), + q.Choice( + title="Commits", + value="commits", + description="Search for commits", + ), + q.Choice( + title="Issues", + value="issues", + description="Search for issues and pull requests", + ), + q.Choice( + title="Topics", + value="topics", + description="Search for topics", + ), + q.Choice( + title="Go Back", + value="back", + description="Go back to ewous menu", + shortcut_key="b", + ), + q.Choice( + title="Quit", + value="quit", + description="Close this session", + shortcut_key="q", + ), + ], + pointer=POINTER, + style=CUSTOM_STYLE, + instruction=INSTRUCTIONS, + use_shortcuts=True, + ).ask() + + if option is None: self.main() + # Handle navigation + if self._navigation(option, self.search, query): + return + + # Handle change query + if option == "change_query": + self.search() + return + + # Execute search if it's a valid method + if option in self.search_methods: + with console.status( + status=f"[dim]Initialising {option} search[/dim]..." + ) as status: + # Get pagination params + + status.stop() + params = prompts.pagination_params() + status.start() + + # Create Search instance + search = Search( + query=query, + page=params["page"], + per_page=params["per_page"], + ) + + method = getattr(search, option) + status.update(f"[dim]Searching {option} for {query}[/dim]...") + data = method() + + if data: + items = data.get("items") + status.stop() + self.response_handling( + data=items if items is not None else data, + data_type=option, + source=query, + ) + + self.search(query=query) + + def user(self, username: t.Optional[str] = None): + """User menu for querying user data.""" + set_menu_title(menu_type="user") + clear_screen() + if username is None: + text_banner(text="User") + username = prompts.prompt( + message="GitHub Username", + instruction="e.g., octocat", + style=CUSTOM_STYLE, + qmark="@", + ) + + clear_screen() + text_banner(text=username) + + option = q.select( + "What would you like to do/get?", + choices=[ + q.Choice( + title="Change Username", + value="change_username", + description="Query a different user", + shortcut_key="u", + ), + q.Choice( + title="Profile", + value="profile", + description="Profile data", + ), + q.Choice( + title="Repositories", + value="repos", + description="Public repositories", + ), + q.Choice( + title="Subscriptions", + value="subscriptions", + description="Subscribed repositories", + ), + q.Choice( + title="Starred", + value="starred", + description="Starred repositories", + ), + q.Choice( + title="Followers", + value="followers", + description="Accounts that follow this user", + ), + q.Choice( + title="Following", + value="following", + description="Accounts that this user follows", + ), + q.Choice( + title="Organisations", + value="orgs", + description="This user's organisations", + ), + q.Choice( + title="Gists", + value="gists", + description="Gists (code snippets/files)", + ), + q.Choice( + title="Events", + value="events", + description="Events", + ), + q.Choice( + title="Received Events", + value="received_events", + description="This user's received events", + ), + q.Choice( + title="Go Back", + value="back", + description="Go back to previous menu", + shortcut_key="b", + ), + q.Choice( + title="Quit", + value="quit", + description="Close this session", + shortcut_key="q", + ), + ], + pointer=POINTER, + style=CUSTOM_STYLE, + instruction=INSTRUCTIONS, + use_shortcuts=True, + ).ask() + + if option is None: + self.main() + # Handle navigation + if self._navigation(option, self.user, username): + return + + # Handle change username + if option == "change_username": + self.user() + return + + # Execute action if it's a valid method + valid_methods = self.paginated_methods | self.non_paginated_methods + if option in valid_methods: + with Status( + status=f"[dim]Initialising user {option}[/dim]...", + console=console, + ) as status: + user = User(name=username) + + status.update(f"[dim]Validating user's ({username}) existence[/dim]...") + if user.exists(): + console.print( + f"[bold][green]✔[/green] User ({username}) exists on GitHub[/bold]" + ) + data = self._execute_selection( + source=username, + instance=user, + method_name=option, + status=status, + ) + + status.stop() + self.response_handling(data=data, data_type=option, source=username) + else: + status.stop() + console.print( + f"[bold][yellow]✘[/yellow] User ({username}) doesn't exist on GitHub[/bold]" + ) + console.input(" Press [bold]ENTER[/bold] to continue ...") + self.user() + + self.user(username=username) + def repo(self, name: t.Optional[str] = None, owner: t.Optional[str] = None): """Repository menu for querying repo data.""" + set_menu_title(menu_type="repo") clear_screen() - try: - if name is None or owner is None: - banner(text="Repository") - if name is None: - name = self.prompts.prompt( - message="GitHub Repo Name", - instruction="e.g., spoon-knife", - ) - if owner is None: - owner = self.prompts.prompt( - message="GitHub Repo Owner", - instruction="e.g., octocat", - ) - - clear_screen() - banner(text=name) - - option = q.select( - "What would you like to do?", - choices=[ - q.Choice( - title="Change repo name", - value="change_repo_name", - description="Update the name of the repo", - ), - q.Choice( - title="Change owner name", - value="change_owner", - description="Update the name of repo owner", - ), - q.Choice( - title="Change both repo and owner names", - value="change_both", - description="Update both the repo and owner names", - ), - q.Choice( - title="Profile", - value="profile", - description="Repository data", - ), - q.Choice( - title="Forks", value="forks", description="Repository forks" - ), - q.Choice( - title="Issue events", - value="issue_events", - description="Issue events", - ), - q.Choice( - title="Events", - value="events", - description="Repository events", - ), - q.Choice( - title="Assignees", - value="assignees", - description="Assignees", - ), - q.Choice( - title="Branches", value="branches", description="Branches" - ), - q.Choice(title="tags", value="tags", description="Tags"), - q.Choice( - title="Languages", - value="languages", - description="Programming languages", - ), - q.Choice( - title="Stargazers", - value="stargazers", - description="Users who starred this repo", - ), - q.Choice( - title="Subscribers", - value="subscribers", - description="Users subscribed to this repo", - ), - q.Choice(title="Commits", value="commits", description="Commits"), - q.Choice( - title="Comments", value="comments", description="Comments" - ), - q.Choice(title="Issues", value="issues", description="Issues"), - q.Choice( - title="Releases", value="releases", description="Releases" - ), - q.Choice( - title="Deployments", - value="deployments", - description="Deployments", - ), - q.Choice(title="labels", value="labels", description="Labels"), - q.Choice( - title="Back", - value="back", - description="Go back to previous menu", - shortcut_key="b", - ), - q.Choice( - title="Quit", - value="quit", - description="Close this session", - shortcut_key="q", - ), - ], - style=CUSTOM_STYLE, - instruction=INSTRUCTIONS, - pointer=POINTER, - use_shortcuts=True, - ).ask() - - # Handle navigation - if self._handle_navigation(option, self.repo, name, owner): - return - - # Handle change options - change_handlers = { - "change_repo_name": lambda: self.repo(owner=owner), - "change_owner": lambda: self.repo(name=name), - "change_both": lambda: self.repo(), - } - - if option in change_handlers: - change_handlers[option]() - return - - # Execute action if it's a valid method - valid_methods = self.paginated_methods | self.non_paginated_methods - if option in valid_methods: - repo = Repo(name=name, owner=owner) - target = f"{owner}/{name}" - data = self._execute_action(repo, option, target) - self.export(data=data, data_type=option, target=target) - - self.repo(name=name, owner=owner) - except KeyboardInterrupt: - print("\n\nReturning to main menu...") - self.main() - - def org(self, name: t.Optional[str] = None): - """Organization menu for querying org data.""" - clear_screen() - try: + if name is None or owner is None: + text_banner(text="Repo") if name is None: - banner(text="org") - name = self.prompts.prompt( - message="GitHub Organization Name", - instruction="e.g, github", + name = prompts.prompt( + message="GitHub Repo Name", + instruction="e.g., spoon-knife", + style=CUSTOM_STYLE, + ) + if owner is None: + owner = prompts.prompt( + message="GitHub Repo Owner", + instruction="e.g., octocat", + style=CUSTOM_STYLE, + qmark="@", ) - clear_screen() - banner(text=name) + clear_screen() + text_banner(text=f"{owner}/{name}") - option = q.select( - "What would you like to do?", - choices=[ - q.Choice( - title="Change org name", - value="change_org", - description="Query a different organization", - ), - q.Choice( - title="Profile", value="profile", description="Profile data" - ), - q.Choice( - title="Repositories", - value="repos", - description="Public repositories", - ), - q.Choice( - title="Events", - value="events", - description="Organization events", - ), - q.Choice(title="Hooks", value="hooks", description="Webhooks"), - q.Choice(title="issues", value="issues", description="Issues"), - q.Choice( - title="Members", - value="members", - description="Organization members", - ), - q.Choice( - title="Back", - value="back", - description="Go back to previous menu", - shortcut_key="b", - ), - q.Choice( - title="Quit", - value="quit", - description="Close this session", - shortcut_key="q", - ), - ], - style=CUSTOM_STYLE, - instruction=INSTRUCTIONS, - pointer=POINTER, - use_shortcuts=True, - ).ask() + option = q.select( + "What would you like to do/get?", + choices=[ + q.Choice( + title="Change Repo Name", + value="change_repo_name", + description="Update the name of the repo", + ), + q.Choice( + title="Change Owner Name", + value="change_owner", + description="Update the name of repo owner", + ), + q.Choice( + title="Change Repo and Owner Names", + value="change_both", + description="Update both the repo and owner names", + ), + q.Choice( + title="Profile", + value="profile", + description="Repository data", + ), + q.Choice( + title="Forks", + value="forks", + description="Repository forks", + ), + q.Choice( + title="Issue Events", + value="issue_events", + description="Issue events", + ), + q.Choice( + title="Events", + value="events", + description="Repository events", + ), + q.Choice( + title="Assignees", + value="assignees", + description="Assignees", + ), + q.Choice( + title="Branches", + value="branches", + description="Branches", + ), + q.Choice( + title="Tags", + value="tags", + description="Tags", + ), + q.Choice( + title="Languages", + value="languages", + description="Programming languages", + ), + q.Choice( + title="Stargazers", + value="stargazers", + description="Users who starred this repo", + ), + q.Choice( + title="Subscribers", + value="subscribers", + description="Users subscribed to this repo", + ), + q.Choice( + title="Commits", + value="commits", + description="Commits", + ), + q.Choice( + title="Comments", + value="comments", + description="Comments", + ), + q.Choice( + title="Issues", + value="issues", + description="Issues", + ), + q.Choice( + title="Releases", + value="releases", + description="Releases", + ), + q.Choice( + title="Deployments", + value="deployments", + description="Deployments", + ), + q.Choice( + title="Labels", + value="labels", + description="Labels", + ), + q.Choice( + title="Go Back", + value="back", + description="Go back to previous menu", + shortcut_key="b", + ), + q.Choice( + title="Quit", + value="quit", + description="Close this session", + shortcut_key="q", + ), + ], + pointer=POINTER, + style=CUSTOM_STYLE, + instruction=INSTRUCTIONS, + use_shortcuts=True, + ).ask() - # Handle navigation - if self._handle_navigation(option, self.org, name): - return - - # Handle change org - if option == "change_org": - self.org() - return - - # Execute action if it's a valid method - valid_methods = self.paginated_methods | self.non_paginated_methods - if option in valid_methods: - org = Org(name=name) - data = self._execute_action(org, option, name) - self.export(data=data, data_type=option, target=name) - - self.org(name=name) - except KeyboardInterrupt: - print("\n\nReturning to main menu...") + if option is None: self.main() + + # Handle navigation + if self._navigation(option, self.repo, name, owner): + return + + # Handle change options + change_handlers = { + "change_repo_name": lambda: self.repo(owner=owner), + "change_owner": lambda: self.repo(name=name), + "change_both": lambda: self.repo(), + } + + if option in change_handlers: + change_handlers[option]() + return + + # Execute action if it's a valid method + valid_methods = self.paginated_methods | self.non_paginated_methods + if option in valid_methods: + source = f"{owner}/{name}" + with Status( + status=f"[dim]Initialising repository {option}[/dim]...", + console=console, + ) as status: + repo = Repo(name=name, owner=owner) + + status.update( + f"[dim]Validating repository's ({source}) existence[/dim]..." + ) + if repo.exists(): + console.print( + f"[bold][green]✔[/green] Repository ({source}) exists on GitHub[/bold]" + ) + data = self._execute_selection( + source=source, instance=repo, method_name=option, status=status + ) + + status.stop() + self.response_handling(data=data, data_type=option, source=source) + else: + status.stop() + console.print( + f"[bold][yellow]✘[/yellow] Repository ({source}) doesn't exist on GitHub[/bold]" + ) + console.input(" Press [bold]ENTER[/bold] to continue ...") + self.repo() + + self.repo(name=name, owner=owner) + + def org(self, name: t.Optional[str] = None): + """Organisation menu for querying org data.""" + set_menu_title(menu_type="org") + clear_screen() + if name is None: + text_banner(text="Org") + name = prompts.prompt( + message="GitHub Organisation Name", + instruction="e.g, github", + style=CUSTOM_STYLE, + qmark="@", + ) + + clear_screen() + text_banner(text=name) + + option = q.select( + "What would you like to do?", + choices=[ + q.Choice( + title="Change Org Name", + value="change_org", + description="Query a different organisation", + ), + q.Choice( + title="Profile", + value="profile", + description="Profile data", + ), + q.Choice( + title="Repositories", + value="repos", + description="Public repositories", + ), + q.Choice( + title="Events", + value="events", + description="Organisation events", + ), + q.Choice( + title="Hooks", + value="hooks", + description="Webhooks", + ), + q.Choice( + title="Issues", + value="issues", + description="Issues", + ), + q.Choice( + title="Members", + value="members", + description="Organisation members", + ), + q.Choice( + title="Go Back", + value="back", + description="Go back to previous menu", + shortcut_key="b", + ), + q.Choice( + title="Quit", + value="quit", + description="Close this session", + shortcut_key="q", + ), + ], + style=CUSTOM_STYLE, + instruction=INSTRUCTIONS, + use_shortcuts=True, + ).ask() + + if option is None: + self.main() + + # Handle navigation + if self._navigation(option, self.org, name): + return + + # Handle change org + if option == "change_org": + self.org() + return + + # Execute action if it's a valid method + valid_methods = self.paginated_methods | self.non_paginated_methods + if option in valid_methods: + with Status( + status=f"[dim]Initialising organisation {option}[/dim]...", + console=console, + ) as status: + org = Org(name=name) + + status.update( + f"[dim]Validating organisation's ({name}) existence[/dim]..." + ) + if org.exists(): + console.print( + f"[bold][green]✔[/green] Organisation ({name}) exists on GitHub[/bold]" + ) + data = self._execute_selection( + source=name, instance=org, method_name=option, status=status + ) + + status.stop() + self.response_handling(data=data, data_type=option, source=name) + else: + status.stop() + console.print( + f"[bold][yellow]✘[/yellow] Organisation ({name}) doesn't exist on GitHub[/bold]" + ) + + console.input(" Press [bold]ENTER[/bold] to continue ...") + self.org() + + self.org(name=name) diff --git a/octosuite/tui/prompts.py b/octosuite/tui/prompts.py index daa7b59..a3abdae 100644 --- a/octosuite/tui/prompts.py +++ b/octosuite/tui/prompts.py @@ -1,7 +1,7 @@ import typing as t import questionary as q -from prompt_toolkit.styles import Style +from questionary import Style class Prompts: @@ -9,21 +9,29 @@ class Prompts: pass @staticmethod - def prompt(message: str, instruction: t.Optional[str] = None) -> str: + def prompt( + message: str, + instruction: t.Optional[str] = None, + style: t.Optional[Style] = None, + qmark: t.Optional[str] = "?", + ) -> str: return q.text( message=message, instruction=instruction, + style=style, + qmark=qmark, + validate=lambda text: len(text.strip()) > 0 or "Input cannot be empty", ).ask() @staticmethod def pagination_params() -> dict: """Prompt user for pagination parameters.""" try: - page = q.text(message="Page", instruction="defaults to 1", qmark="#").ask() + page = q.text(message="Page", default="1", qmark="n").ask() per_page = q.text( message="Per Page", - instruction="default and max is 100", - qmark="#", + default="100", + qmark="n", ).ask() return { @@ -34,21 +42,3 @@ class Prompts: except (ValueError, TypeError): print("Invalid input, using defaults (page=1, per_page=100)") return {"page": 1, "per_page": 100} - - @staticmethod - def quit() -> bool: - try: - if q.confirm( - "This will close the session, continue?", - default=True, - style=Style( - [ - ("qmark", "fg:red bold"), - ("question", "fg:red bold"), - ] - ), - ).ask(): - return True - return False - except KeyboardInterrupt: - return True diff --git a/pyproject.toml b/pyproject.toml index e7d6464..5c0b8a7 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -10,10 +10,9 @@ authors = [ requires-python = ">=3.13" dependencies = [ "rich>=14.2.0", - "requests>=2.32.5", "questionary>=2.1.1", "pyfiglet>=1.0.4", - "pandas>=2.3.3" + "update-checker>=0.18.0" ] classifiers = [ "Development Status :: 4 - Beta", @@ -28,5 +27,10 @@ homepage = "https://bellingcat.com" issues = "https://github.com/bellingcat/octosuite/issues" repository = "https://github.com/bellingcat/octosuite" +[project.optional-dependencies] +dev = [ + "black>=25.12.0", +] + [project.scripts] octosuite = "octosuite.main:start" \ No newline at end of file diff --git a/uv.lock b/uv.lock index b83449a..15a8f1f 100644 --- a/uv.lock +++ b/uv.lock @@ -1,6 +1,33 @@ version = 1 revision = 2 -requires-python = ">=3.14" +requires-python = ">=3.13" + +[[package]] +name = "black" +version = "25.12.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, + { name = "mypy-extensions" }, + { name = "packaging" }, + { name = "pathspec" }, + { name = "platformdirs" }, + { 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" } +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/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/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/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/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/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/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/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/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/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/68/11/21331aed19145a952ad28fca2756a1433ee9308079bd03bd898e903a2e53/black-25.12.0-py3-none-any.whl", hash = "sha256:48ceb36c16dbc84062740049eef990bb2ce07598272e673c17d1a7720c71c828", size = 206191, upload-time = "2025-12-08T01:40:50.963Z" }, +] [[package]] name = "certifi" @@ -17,6 +44,22 @@ version = "3.4.4" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/13/69/33ddede1939fdd074bce5434295f38fae7136463422fe4fd3e0e89b98062/charset_normalizer-3.4.4.tar.gz", hash = "sha256:94537985111c35f28720e43603b8e7b43a6ecfb2ce1d3058bbe955b73404e21a", size = 129418, upload-time = "2025-10-14T04:42:32.879Z" } wheels = [ + { url = "https://files.pythonhosted.org/packages/97/45/4b3a1239bbacd321068ea6e7ac28875b03ab8bc0aa0966452db17cd36714/charset_normalizer-3.4.4-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:e1f185f86a6f3403aa2420e815904c67b2f9ebc443f045edd0de921108345794", size = 208091, upload-time = "2025-10-14T04:41:13.346Z" }, + { url = "https://files.pythonhosted.org/packages/7d/62/73a6d7450829655a35bb88a88fca7d736f9882a27eacdca2c6d505b57e2e/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6b39f987ae8ccdf0d2642338faf2abb1862340facc796048b604ef14919e55ed", size = 147936, upload-time = "2025-10-14T04:41:14.461Z" }, + { url = "https://files.pythonhosted.org/packages/89/c5/adb8c8b3d6625bef6d88b251bbb0d95f8205831b987631ab0c8bb5d937c2/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:3162d5d8ce1bb98dd51af660f2121c55d0fa541b46dff7bb9b9f86ea1d87de72", size = 144180, upload-time = "2025-10-14T04:41:15.588Z" }, + { url = "https://files.pythonhosted.org/packages/91/ed/9706e4070682d1cc219050b6048bfd293ccf67b3d4f5a4f39207453d4b99/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:81d5eb2a312700f4ecaa977a8235b634ce853200e828fbadf3a9c50bab278328", size = 161346, upload-time = "2025-10-14T04:41:16.738Z" }, + { url = "https://files.pythonhosted.org/packages/d5/0d/031f0d95e4972901a2f6f09ef055751805ff541511dc1252ba3ca1f80cf5/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5bd2293095d766545ec1a8f612559f6b40abc0eb18bb2f5d1171872d34036ede", size = 158874, upload-time = "2025-10-14T04:41:17.923Z" }, + { url = "https://files.pythonhosted.org/packages/f5/83/6ab5883f57c9c801ce5e5677242328aa45592be8a00644310a008d04f922/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a8a8b89589086a25749f471e6a900d3f662d1d3b6e2e59dcecf787b1cc3a1894", size = 153076, upload-time = "2025-10-14T04:41:19.106Z" }, + { url = "https://files.pythonhosted.org/packages/75/1e/5ff781ddf5260e387d6419959ee89ef13878229732732ee73cdae01800f2/charset_normalizer-3.4.4-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:bc7637e2f80d8530ee4a78e878bce464f70087ce73cf7c1caf142416923b98f1", size = 150601, upload-time = "2025-10-14T04:41:20.245Z" }, + { url = "https://files.pythonhosted.org/packages/d7/57/71be810965493d3510a6ca79b90c19e48696fb1ff964da319334b12677f0/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f8bf04158c6b607d747e93949aa60618b61312fe647a6369f88ce2ff16043490", size = 150376, upload-time = "2025-10-14T04:41:21.398Z" }, + { url = "https://files.pythonhosted.org/packages/e5/d5/c3d057a78c181d007014feb7e9f2e65905a6c4ef182c0ddf0de2924edd65/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:554af85e960429cf30784dd47447d5125aaa3b99a6f0683589dbd27e2f45da44", size = 144825, upload-time = "2025-10-14T04:41:22.583Z" }, + { url = "https://files.pythonhosted.org/packages/e6/8c/d0406294828d4976f275ffbe66f00266c4b3136b7506941d87c00cab5272/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:74018750915ee7ad843a774364e13a3db91682f26142baddf775342c3f5b1133", size = 162583, upload-time = "2025-10-14T04:41:23.754Z" }, + { url = "https://files.pythonhosted.org/packages/d7/24/e2aa1f18c8f15c4c0e932d9287b8609dd30ad56dbe41d926bd846e22fb8d/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:c0463276121fdee9c49b98908b3a89c39be45d86d1dbaa22957e38f6321d4ce3", size = 150366, upload-time = "2025-10-14T04:41:25.27Z" }, + { url = "https://files.pythonhosted.org/packages/e4/5b/1e6160c7739aad1e2df054300cc618b06bf784a7a164b0f238360721ab86/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:362d61fd13843997c1c446760ef36f240cf81d3ebf74ac62652aebaf7838561e", size = 160300, upload-time = "2025-10-14T04:41:26.725Z" }, + { url = "https://files.pythonhosted.org/packages/7a/10/f882167cd207fbdd743e55534d5d9620e095089d176d55cb22d5322f2afd/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9a26f18905b8dd5d685d6d07b0cdf98a79f3c7a918906af7cc143ea2e164c8bc", size = 154465, upload-time = "2025-10-14T04:41:28.322Z" }, + { url = "https://files.pythonhosted.org/packages/89/66/c7a9e1b7429be72123441bfdbaf2bc13faab3f90b933f664db506dea5915/charset_normalizer-3.4.4-cp313-cp313-win32.whl", hash = "sha256:9b35f4c90079ff2e2edc5b26c0c77925e5d2d255c42c74fdb70fb49b172726ac", size = 99404, upload-time = "2025-10-14T04:41:29.95Z" }, + { url = "https://files.pythonhosted.org/packages/c4/26/b9924fa27db384bdcd97ab83b4f0a8058d96ad9626ead570674d5e737d90/charset_normalizer-3.4.4-cp313-cp313-win_amd64.whl", hash = "sha256:b435cba5f4f750aa6c0a0d92c541fb79f69a387c91e61f1795227e4ed9cece14", size = 107092, upload-time = "2025-10-14T04:41:31.188Z" }, + { url = "https://files.pythonhosted.org/packages/af/8f/3ed4bfa0c0c72a7ca17f0380cd9e4dd842b09f664e780c13cff1dcf2ef1b/charset_normalizer-3.4.4-cp313-cp313-win_arm64.whl", hash = "sha256:542d2cee80be6f80247095cc36c418f7bddd14f4a6de45af91dfad36d817bba2", size = 100408, upload-time = "2025-10-14T04:41:32.624Z" }, { url = "https://files.pythonhosted.org/packages/2a/35/7051599bd493e62411d6ede36fd5af83a38f37c4767b92884df7301db25d/charset_normalizer-3.4.4-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:da3326d9e65ef63a817ecbcc0df6e94463713b754fe293eaa03da99befb9a5bd", size = 207746, upload-time = "2025-10-14T04:41:33.773Z" }, { url = "https://files.pythonhosted.org/packages/10/9a/97c8d48ef10d6cd4fcead2415523221624bf58bcf68a802721a6bc807c8f/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8af65f14dc14a79b924524b1e7fffe304517b2bff5a58bf64f30b98bbc5079eb", size = 147889, upload-time = "2025-10-14T04:41:34.897Z" }, { url = "https://files.pythonhosted.org/packages/10/bf/979224a919a1b606c82bd2c5fa49b5c6d5727aa47b4312bb27b1734f53cd/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:74664978bb272435107de04e36db5a9735e78232b85b77d45cfb38f758efd33e", size = 143641, upload-time = "2025-10-14T04:41:36.116Z" }, @@ -36,6 +79,27 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/0a/4c/925909008ed5a988ccbb72dcc897407e5d6d3bd72410d69e051fc0c14647/charset_normalizer-3.4.4-py3-none-any.whl", hash = "sha256:7a32c560861a02ff789ad905a2fe94e3f840803362c84fecf1851cb4cf3dc37f", size = 53402, upload-time = "2025-10-14T04:42:31.76Z" }, ] +[[package]] +name = "click" +version = "8.3.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/3d/fa/656b739db8587d7b5dfa22e22ed02566950fbfbcdc20311993483657a5c0/click-8.3.1.tar.gz", hash = "sha256:12ff4785d337a1bb490bb7e9c2b1ee5da3112e94a8622f26a6c77f5d2fc6842a", size = 295065, upload-time = "2025-11-15T20:45:42.706Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/98/78/01c019cdb5d6498122777c1a43056ebb3ebfeef2076d9d026bfe15583b2b/click-8.3.1-py3-none-any.whl", hash = "sha256:981153a64e25f12d547d3426c367a4857371575ee7ad18df2a6183ab0545b2a6", size = 108274, upload-time = "2025-11-15T20:45:41.139Z" }, +] + +[[package]] +name = "colorama" +version = "0.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, +] + [[package]] name = "idna" version = "3.11" @@ -67,80 +131,65 @@ wheels = [ ] [[package]] -name = "numpy" -version = "2.4.0" +name = "mypy-extensions" +version = "1.1.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/a4/7a/6a3d14e205d292b738db449d0de649b373a59edb0d0b4493821d0a3e8718/numpy-2.4.0.tar.gz", hash = "sha256:6e504f7b16118198f138ef31ba24d985b124c2c469fe8467007cf30fd992f934", size = 20685720, upload-time = "2025-12-20T16:18:19.023Z" } +sdist = { url = "https://files.pythonhosted.org/packages/a2/6e/371856a3fb9d31ca8dac321cda606860fa4548858c0cc45d9d1d4ca2628b/mypy_extensions-1.1.0.tar.gz", hash = "sha256:52e68efc3284861e772bbcd66823fde5ae21fd2fdb51c62a211403730b916558", size = 6343, upload-time = "2025-04-22T14:54:24.164Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/ab/ed/52eac27de39d5e5a6c9aadabe672bc06f55e24a3d9010cd1183948055d76/numpy-2.4.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:c95eb6db2884917d86cde0b4d4cf31adf485c8ec36bf8696dd66fa70de96f36b", size = 16647476, upload-time = "2025-12-20T16:17:17.671Z" }, - { url = "https://files.pythonhosted.org/packages/77/c0/990ce1b7fcd4e09aeaa574e2a0a839589e4b08b2ca68070f1acb1fea6736/numpy-2.4.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:65167da969cd1ec3a1df31cb221ca3a19a8aaa25370ecb17d428415e93c1935e", size = 12374563, upload-time = "2025-12-20T16:17:20.216Z" }, - { url = "https://files.pythonhosted.org/packages/37/7c/8c5e389c6ae8f5fd2277a988600d79e9625db3fff011a2d87ac80b881a4c/numpy-2.4.0-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:3de19cfecd1465d0dcf8a5b5ea8b3155b42ed0b639dba4b71e323d74f2a3be5e", size = 5203107, upload-time = "2025-12-20T16:17:22.47Z" }, - { url = "https://files.pythonhosted.org/packages/e6/94/ca5b3bd6a8a70a5eec9a0b8dd7f980c1eff4b8a54970a9a7fef248ef564f/numpy-2.4.0-cp314-cp314-macosx_14_0_x86_64.whl", hash = "sha256:6c05483c3136ac4c91b4e81903cb53a8707d316f488124d0398499a4f8e8ef51", size = 6538067, upload-time = "2025-12-20T16:17:24.001Z" }, - { url = "https://files.pythonhosted.org/packages/79/43/993eb7bb5be6761dde2b3a3a594d689cec83398e3f58f4758010f3b85727/numpy-2.4.0-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:36667db4d6c1cea79c8930ab72fadfb4060feb4bfe724141cd4bd064d2e5f8ce", size = 14411926, upload-time = "2025-12-20T16:17:25.822Z" }, - { url = "https://files.pythonhosted.org/packages/03/75/d4c43b61de473912496317a854dac54f1efec3eeb158438da6884b70bb90/numpy-2.4.0-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9a818668b674047fd88c4cddada7ab8f1c298812783e8328e956b78dc4807f9f", size = 16354295, upload-time = "2025-12-20T16:17:28.308Z" }, - { url = "https://files.pythonhosted.org/packages/b8/0a/b54615b47ee8736a6461a4bb6749128dd3435c5a759d5663f11f0e9af4ac/numpy-2.4.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:1ee32359fb7543b7b7bd0b2f46294db27e29e7bbdf70541e81b190836cd83ded", size = 16190242, upload-time = "2025-12-20T16:17:30.993Z" }, - { url = "https://files.pythonhosted.org/packages/98/ce/ea207769aacad6246525ec6c6bbd66a2bf56c72443dc10e2f90feed29290/numpy-2.4.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:e493962256a38f58283de033d8af176c5c91c084ea30f15834f7545451c42059", size = 18280875, upload-time = "2025-12-20T16:17:33.327Z" }, - { url = "https://files.pythonhosted.org/packages/17/ef/ec409437aa962ea372ed601c519a2b141701683ff028f894b7466f0ab42b/numpy-2.4.0-cp314-cp314-win32.whl", hash = "sha256:6bbaebf0d11567fa8926215ae731e1d58e6ec28a8a25235b8a47405d301332db", size = 6002530, upload-time = "2025-12-20T16:17:35.729Z" }, - { url = "https://files.pythonhosted.org/packages/5f/4a/5cb94c787a3ed1ac65e1271b968686521169a7b3ec0b6544bb3ca32960b0/numpy-2.4.0-cp314-cp314-win_amd64.whl", hash = "sha256:3d857f55e7fdf7c38ab96c4558c95b97d1c685be6b05c249f5fdafcbd6f9899e", size = 12435890, upload-time = "2025-12-20T16:17:37.599Z" }, - { url = "https://files.pythonhosted.org/packages/48/a0/04b89db963af9de1104975e2544f30de89adbf75b9e75f7dd2599be12c79/numpy-2.4.0-cp314-cp314-win_arm64.whl", hash = "sha256:bb50ce5fb202a26fd5404620e7ef820ad1ab3558b444cb0b55beb7ef66cd2d63", size = 10591892, upload-time = "2025-12-20T16:17:39.649Z" }, - { url = "https://files.pythonhosted.org/packages/53/e5/d74b5ccf6712c06c7a545025a6a71bfa03bdc7e0568b405b0d655232fd92/numpy-2.4.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:355354388cba60f2132df297e2d53053d4063f79077b67b481d21276d61fc4df", size = 12494312, upload-time = "2025-12-20T16:17:41.714Z" }, - { url = "https://files.pythonhosted.org/packages/c2/08/3ca9cc2ddf54dfee7ae9a6479c071092a228c68aef08252aa08dac2af002/numpy-2.4.0-cp314-cp314t-macosx_14_0_arm64.whl", hash = "sha256:1d8f9fde5f6dc1b6fc34df8162f3b3079365468703fee7f31d4e0cc8c63baed9", size = 5322862, upload-time = "2025-12-20T16:17:44.145Z" }, - { url = "https://files.pythonhosted.org/packages/87/74/0bb63a68394c0c1e52670cfff2e309afa41edbe11b3327d9af29e4383f34/numpy-2.4.0-cp314-cp314t-macosx_14_0_x86_64.whl", hash = "sha256:e0434aa22c821f44eeb4c650b81c7fbdd8c0122c6c4b5a576a76d5a35625ecd9", size = 6644986, upload-time = "2025-12-20T16:17:46.203Z" }, - { url = "https://files.pythonhosted.org/packages/06/8f/9264d9bdbcf8236af2823623fe2f3981d740fc3461e2787e231d97c38c28/numpy-2.4.0-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:40483b2f2d3ba7aad426443767ff5632ec3156ef09742b96913787d13c336471", size = 14457958, upload-time = "2025-12-20T16:17:48.017Z" }, - { url = "https://files.pythonhosted.org/packages/8c/d9/f9a69ae564bbc7236a35aa883319364ef5fd41f72aa320cc1cbe66148fe2/numpy-2.4.0-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d9e6a7664ddd9746e20b7325351fe1a8408d0a2bf9c63b5e898290ddc8f09544", size = 16398394, upload-time = "2025-12-20T16:17:50.409Z" }, - { url = "https://files.pythonhosted.org/packages/34/c7/39241501408dde7f885d241a98caba5421061a2c6d2b2197ac5e3aa842d8/numpy-2.4.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:ecb0019d44f4cdb50b676c5d0cb4b1eae8e15d1ed3d3e6639f986fc92b2ec52c", size = 16241044, upload-time = "2025-12-20T16:17:52.661Z" }, - { url = "https://files.pythonhosted.org/packages/7c/95/cae7effd90e065a95e59fe710eeee05d7328ed169776dfdd9f789e032125/numpy-2.4.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:d0ffd9e2e4441c96a9c91ec1783285d80bf835b677853fc2770a89d50c1e48ac", size = 18321772, upload-time = "2025-12-20T16:17:54.947Z" }, - { url = "https://files.pythonhosted.org/packages/96/df/3c6c279accd2bfb968a76298e5b276310bd55d243df4fa8ac5816d79347d/numpy-2.4.0-cp314-cp314t-win32.whl", hash = "sha256:77f0d13fa87036d7553bf81f0e1fe3ce68d14c9976c9851744e4d3e91127e95f", size = 6148320, upload-time = "2025-12-20T16:17:57.249Z" }, - { url = "https://files.pythonhosted.org/packages/92/8d/f23033cce252e7a75cae853d17f582e86534c46404dea1c8ee094a9d6d84/numpy-2.4.0-cp314-cp314t-win_amd64.whl", hash = "sha256:b1f5b45829ac1848893f0ddf5cb326110604d6df96cdc255b0bf9edd154104d4", size = 12623460, upload-time = "2025-12-20T16:17:58.963Z" }, - { url = "https://files.pythonhosted.org/packages/a4/4f/1f8475907d1a7c4ef9020edf7f39ea2422ec896849245f00688e4b268a71/numpy-2.4.0-cp314-cp314t-win_arm64.whl", hash = "sha256:23a3e9d1a6f360267e8fbb38ba5db355a6a7e9be71d7fce7ab3125e88bb646c8", size = 10661799, upload-time = "2025-12-20T16:18:01.078Z" }, + { url = "https://files.pythonhosted.org/packages/79/7b/2c79738432f5c924bef5071f933bcc9efd0473bac3b4aa584a6f7c1c8df8/mypy_extensions-1.1.0-py3-none-any.whl", hash = "sha256:1be4cccdb0f2482337c4743e60421de3a356cd97508abadd57d47403e94f5505", size = 4963, upload-time = "2025-04-22T14:54:22.983Z" }, ] [[package]] name = "octosuite" version = "4.0.0" -source = { editable = "." } +source = { virtual = "." } dependencies = [ - { name = "pandas" }, { name = "pyfiglet" }, { name = "questionary" }, - { name = "requests" }, { name = "rich" }, + { name = "update-checker" }, +] + +[package.optional-dependencies] +dev = [ + { name = "black" }, ] [package.metadata] requires-dist = [ - { name = "pandas", specifier = ">=2.3.3,<3.0.0" }, - { name = "pyfiglet", specifier = ">=1.0.4,<2.0.0" }, - { name = "questionary", specifier = ">=2.1.1,<3.0.0" }, - { name = "requests", specifier = ">=2.32.5,<3.0.0" }, - { name = "rich", specifier = ">=14.2.0,<15.0.0" }, + { name = "black", marker = "extra == 'dev'", specifier = ">=25.12.0" }, + { name = "pyfiglet", specifier = ">=1.0.4" }, + { name = "questionary", specifier = ">=2.1.1" }, + { name = "rich", specifier = ">=14.2.0" }, + { name = "update-checker", specifier = ">=0.18.0" }, +] +provides-extras = ["dev"] + +[[package]] +name = "packaging" +version = "25.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a1/d4/1fc4078c65507b51b96ca8f8c3ba19e6a61c8253c72794544580a7b6c24d/packaging-25.0.tar.gz", hash = "sha256:d443872c98d677bf60f6a1f2f8c1cb748e8fe762d2bf9d3148b5599295b0fc4f", size = 165727, upload-time = "2025-04-19T11:48:59.673Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/20/12/38679034af332785aac8774540895e234f4d07f7545804097de4b666afd8/packaging-25.0-py3-none-any.whl", hash = "sha256:29572ef2b1f17581046b3a2227d5c611fb25ec70ca1ba8554b24b0e69331a484", size = 66469, upload-time = "2025-04-19T11:48:57.875Z" }, ] [[package]] -name = "pandas" -version = "2.3.3" +name = "pathspec" +version = "0.12.1" source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "numpy" }, - { name = "python-dateutil" }, - { name = "pytz" }, - { name = "tzdata" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/33/01/d40b85317f86cf08d853a4f495195c73815fdf205eef3993821720274518/pandas-2.3.3.tar.gz", hash = "sha256:e05e1af93b977f7eafa636d043f9f94c7ee3ac81af99c13508215942e64c993b", size = 4495223, upload-time = "2025-09-29T23:34:51.853Z" } +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" } wheels = [ - { url = "https://files.pythonhosted.org/packages/04/fd/74903979833db8390b73b3a8a7d30d146d710bd32703724dd9083950386f/pandas-2.3.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:ee15f284898e7b246df8087fc82b87b01686f98ee67d85a17b7ab44143a3a9a0", size = 11540635, upload-time = "2025-09-29T23:25:52.486Z" }, - { url = "https://files.pythonhosted.org/packages/21/00/266d6b357ad5e6d3ad55093a7e8efc7dd245f5a842b584db9f30b0f0a287/pandas-2.3.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:1611aedd912e1ff81ff41c745822980c49ce4a7907537be8692c8dbc31924593", size = 10759079, upload-time = "2025-09-29T23:26:33.204Z" }, - { url = "https://files.pythonhosted.org/packages/ca/05/d01ef80a7a3a12b2f8bbf16daba1e17c98a2f039cbc8e2f77a2c5a63d382/pandas-2.3.3-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6d2cefc361461662ac48810cb14365a365ce864afe85ef1f447ff5a1e99ea81c", size = 11814049, upload-time = "2025-09-29T23:27:15.384Z" }, - { url = "https://files.pythonhosted.org/packages/15/b2/0e62f78c0c5ba7e3d2c5945a82456f4fac76c480940f805e0b97fcbc2f65/pandas-2.3.3-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ee67acbbf05014ea6c763beb097e03cd629961c8a632075eeb34247120abcb4b", size = 12332638, upload-time = "2025-09-29T23:27:51.625Z" }, - { url = "https://files.pythonhosted.org/packages/c5/33/dd70400631b62b9b29c3c93d2feee1d0964dc2bae2e5ad7a6c73a7f25325/pandas-2.3.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:c46467899aaa4da076d5abc11084634e2d197e9460643dd455ac3db5856b24d6", size = 12886834, upload-time = "2025-09-29T23:28:21.289Z" }, - { url = "https://files.pythonhosted.org/packages/d3/18/b5d48f55821228d0d2692b34fd5034bb185e854bdb592e9c640f6290e012/pandas-2.3.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:6253c72c6a1d990a410bc7de641d34053364ef8bcd3126f7e7450125887dffe3", size = 13409925, upload-time = "2025-09-29T23:28:58.261Z" }, - { url = "https://files.pythonhosted.org/packages/a6/3d/124ac75fcd0ecc09b8fdccb0246ef65e35b012030defb0e0eba2cbbbe948/pandas-2.3.3-cp314-cp314-win_amd64.whl", hash = "sha256:1b07204a219b3b7350abaae088f451860223a52cfb8a6c53358e7948735158e5", size = 11109071, upload-time = "2025-09-29T23:32:27.484Z" }, - { url = "https://files.pythonhosted.org/packages/89/9c/0e21c895c38a157e0faa1fb64587a9226d6dd46452cac4532d80c3c4a244/pandas-2.3.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:2462b1a365b6109d275250baaae7b760fd25c726aaca0054649286bcfbb3e8ec", size = 12048504, upload-time = "2025-09-29T23:29:31.47Z" }, - { url = "https://files.pythonhosted.org/packages/d7/82/b69a1c95df796858777b68fbe6a81d37443a33319761d7c652ce77797475/pandas-2.3.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:0242fe9a49aa8b4d78a4fa03acb397a58833ef6199e9aa40a95f027bb3a1b6e7", size = 11410702, upload-time = "2025-09-29T23:29:54.591Z" }, - { url = "https://files.pythonhosted.org/packages/f9/88/702bde3ba0a94b8c73a0181e05144b10f13f29ebfc2150c3a79062a8195d/pandas-2.3.3-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a21d830e78df0a515db2b3d2f5570610f5e6bd2e27749770e8bb7b524b89b450", size = 11634535, upload-time = "2025-09-29T23:30:21.003Z" }, - { url = "https://files.pythonhosted.org/packages/a4/1e/1bac1a839d12e6a82ec6cb40cda2edde64a2013a66963293696bbf31fbbb/pandas-2.3.3-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2e3ebdb170b5ef78f19bfb71b0dc5dc58775032361fa188e814959b74d726dd5", size = 12121582, upload-time = "2025-09-29T23:30:43.391Z" }, - { url = "https://files.pythonhosted.org/packages/44/91/483de934193e12a3b1d6ae7c8645d083ff88dec75f46e827562f1e4b4da6/pandas-2.3.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:d051c0e065b94b7a3cea50eb1ec32e912cd96dba41647eb24104b6c6c14c5788", size = 12699963, upload-time = "2025-09-29T23:31:10.009Z" }, - { url = "https://files.pythonhosted.org/packages/70/44/5191d2e4026f86a2a109053e194d3ba7a31a2d10a9c2348368c63ed4e85a/pandas-2.3.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:3869faf4bd07b3b66a9f462417d0ca3a9df29a9f6abd5d0d0dbab15dac7abe87", size = 13202175, upload-time = "2025-09-29T23:31:59.173Z" }, + { 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" }, +] + +[[package]] +name = "platformdirs" +version = "4.5.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/cf/86/0248f086a84f01b37aaec0fa567b397df1a119f73c16f6c7a9aac73ea309/platformdirs-4.5.1.tar.gz", hash = "sha256:61d5cdcc6065745cdd94f0f878977f8de9437be93de97c1c12f853c9c0cdcbda", size = 21715, upload-time = "2025-12-05T13:52:58.638Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/28/3bfe2fa5a7b9c46fe7e13c97bda14c895fb10fa2ebf1d0abb90e0cea7ee1/platformdirs-4.5.1-py3-none-any.whl", hash = "sha256:d03afa3963c806a9bed9d5125c8f4cb2fdaf74a55ab60e5d59b3fde758104d31", size = 18731, upload-time = "2025-12-05T13:52:56.823Z" }, ] [[package]] @@ -174,24 +223,12 @@ wheels = [ ] [[package]] -name = "python-dateutil" -version = "2.9.0.post0" +name = "pytokens" +version = "0.3.0" source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "six" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/66/c0/0c8b6ad9f17a802ee498c46e004a0eb49bc148f2fd230864601a86dcf6db/python-dateutil-2.9.0.post0.tar.gz", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3", size = 342432, upload-time = "2024-03-01T18:36:20.211Z" } +sdist = { url = "https://files.pythonhosted.org/packages/4e/8d/a762be14dae1c3bf280202ba3172020b2b0b4c537f94427435f19c413b72/pytokens-0.3.0.tar.gz", hash = "sha256:2f932b14ed08de5fcf0b391ace2642f858f1394c0857202959000b68ed7a458a", size = 17644, upload-time = "2025-11-05T13:36:35.34Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/ec/57/56b9bcc3c9c6a792fcbaf139543cee77261f3651ca9da0c93f5c1221264b/python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427", size = 229892, upload-time = "2024-03-01T18:36:18.57Z" }, -] - -[[package]] -name = "pytz" -version = "2025.2" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/f8/bf/abbd3cdfb8fbc7fb3d4d38d320f2441b1e7cbe29be4f23797b4a2b5d8aac/pytz-2025.2.tar.gz", hash = "sha256:360b9e3dbb49a209c21ad61809c7fb453643e048b38924c765813546746e81c3", size = 320884, upload-time = "2025-03-25T02:25:00.538Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/81/c4/34e93fe5f5429d7570ec1fa436f1986fb1f00c3e0f43a589fe2bbcd22c3f/pytz-2025.2-py2.py3-none-any.whl", hash = "sha256:5ddf76296dd8c44c26eb8f4b6f35488f3ccbf6fbbd7adee0b7262d43f0ec2f00", size = 509225, upload-time = "2025-03-25T02:24:58.468Z" }, + { url = "https://files.pythonhosted.org/packages/84/25/d9db8be44e205a124f6c98bc0324b2bb149b7431c53877fc6d1038dddaf5/pytokens-0.3.0-py3-none-any.whl", hash = "sha256:95b2b5eaf832e469d141a378872480ede3f251a5a5041b8ec6e581d3ac71bbf3", size = 12195, upload-time = "2025-11-05T13:36:33.183Z" }, ] [[package]] @@ -235,21 +272,15 @@ wheels = [ ] [[package]] -name = "six" -version = "1.17.0" +name = "update-checker" +version = "0.18.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/94/e7/b2c673351809dca68a0e064b6af791aa332cf192da575fd474ed7d6f16a2/six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81", size = 34031, upload-time = "2024-12-04T17:35:28.174Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050, upload-time = "2024-12-04T17:35:26.475Z" }, +dependencies = [ + { name = "requests" }, ] - -[[package]] -name = "tzdata" -version = "2025.3" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/5e/a7/c202b344c5ca7daf398f3b8a477eeb205cf3b6f32e7ec3a6bac0629ca975/tzdata-2025.3.tar.gz", hash = "sha256:de39c2ca5dc7b0344f2eba86f49d614019d29f060fc4ebc8a417896a620b56a7", size = 196772, upload-time = "2025-12-13T17:45:35.667Z" } +sdist = { url = "https://files.pythonhosted.org/packages/5c/0b/1bec4a6cc60d33ce93d11a7bcf1aeffc7ad0aa114986073411be31395c6f/update_checker-0.18.0.tar.gz", hash = "sha256:6a2d45bb4ac585884a6b03f9eade9161cedd9e8111545141e9aa9058932acb13", size = 6699, upload-time = "2020-08-04T07:08:50.429Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/c7/b0/003792df09decd6849a5e39c28b513c06e84436a54440380862b5aeff25d/tzdata-2025.3-py2.py3-none-any.whl", hash = "sha256:06a47e5700f3081aab02b2e513160914ff0694bce9947d6b76ebd6bf57cfc5d1", size = 348521, upload-time = "2025-12-13T17:45:33.889Z" }, + { url = "https://files.pythonhosted.org/packages/0c/ba/8dd7fa5f0b1c6a8ac62f8f57f7e794160c1f86f31c6d0fb00f582372a3e4/update_checker-0.18.0-py3-none-any.whl", hash = "sha256:cbba64760a36fe2640d80d85306e8fe82b6816659190993b7bdabadee4d4bbfd", size = 7008, upload-time = "2020-08-04T07:08:49.51Z" }, ] [[package]]