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"| {key} | \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"| {value} | \n")
+ file.write("
\n")
+ file.write("\n")
+
+ file.write("
\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]]