This commit is contained in:
Ritchie Mwewa
2026-01-03 03:44:28 +02:00
parent b0e98fe386
commit b0aef9e204
11 changed files with 1267 additions and 682 deletions

33
Makefile Normal file
View File

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

View File

@@ -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("<!DOCTYPE html>\n<html>\n<head>\n")
file.write('<meta charset="UTF-8">\n')
file.write("<style>table { border-collapse: collapse; width: 100%; }\n")
file.write(
"th, td { border: 1px solid black; padding: 8px; text-align: left; }\n"
)
file.write("th { background-color: #f2f2f2; }</style>\n")
file.write("</head>\n<body>\n")
file.write('<table class="table table-striped" border="1">\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("<thead>\n<tr>\n")
for key in keys:
file.write(f"<th>{key}</th>\n")
file.write("</tr>\n</thead>\n")
# Write rows
file.write("<tbody>\n")
for item in data_list:
if isinstance(item, dict):
file.write("<tr>\n")
for key in keys:
value = item.get(key, "")
file.write(f"<td>{value}</td>\n")
file.write("</tr>\n")
file.write("</tbody>\n")
file.write("</table>\n</body>\n</html>")
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)

View File

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

77
octosuite/core/github.py Normal file
View File

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

View File

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

View File

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

44
octosuite/tui/dialogs.py Normal file
View File

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

View File

@@ -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 {}
)
# Execute with status spinner
with console.status(f"Fetching {method_name} for {target}..."):
status.start()
status.update(f"[dim]Getting {method_name} from {source}...[/dim]")
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,70 +103,47 @@ 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}")
preview_response(data=data, source=source, _type=data_type)
try:
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 export_choice == "skip":
return
# Show what was found
if isinstance(data, list):
console.print(f"\n✓ Found {len(data)} valid {data_type} for {target}")
if export_choice == "quit":
if dialogs.quit():
sys.exit()
else:
console.print(f"\n✓ Found valid {data_type} data for {target}")
# Ask for export formats (multi-select)
try:
self._preview_data(data=data)
if not q.confirm(
"Would you like to export this data?",
default=True,
).ask():
console.print("[yellow]Skipping export.[/yellow]")
self.response_handling(
data=data, data_type=data_type, source=source
)
return
file_formats = q.checkbox(
@@ -261,75 +165,234 @@ 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")
action = q.select(
"What action would you like to perform?",
choices=[
q.Choice(
title="User",
value="user",
description="data about a user",
),
q.Choice(
title="Repo",
value="repo",
description="data about a repository",
),
q.Choice(
title="Org",
value="org",
description="data about an organisation",
),
q.Choice(
title="Search",
value="search",
description="Search GitHub",
),
q.Choice(
title="Updates",
value="updates",
description="Check for updates",
),
q.Choice(
title="License",
value="license",
description="Read license notice, and copyright information",
),
q.Choice(
title="Quit",
value="quit",
description="Close this session",
),
],
pointer=POINTER,
style=CUSTOM_STYLE,
instruction=INSTRUCTIONS,
).ask()
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:
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()
banner(text=username)
text_banner(text=query)
option = q.select(
"Select an action",
"What would you like to do/search?",
choices=[
q.Choice(
title="Profile", value="profile", description="Profile data"
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",
@@ -366,20 +429,18 @@ class Menus:
value="gists",
description="Gists (code snippets/files)",
),
q.Choice(title="events", value="events", description="Events"),
q.Choice(
title="Received events",
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",
title="Go Back",
value="back",
description="Go back to previous menu",
shortcut_key="b",
@@ -391,14 +452,16 @@ class Menus:
shortcut_key="q",
),
],
pointer=POINTER,
style=CUSTOM_STYLE,
instruction=INSTRUCTIONS,
pointer=POINTER,
use_shortcuts=True,
).ask()
if option is None:
self.main()
# Handle navigation
if self._handle_navigation(option, self.user, username):
if self._navigation(option, self.user, username):
return
# Handle change username
@@ -409,50 +472,74 @@ class Menus:
# 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)
data = self._execute_action(user, option, username)
self.export(data=data, data_type=option, target=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)
except KeyboardInterrupt:
print("\n\nReturning to main menu...")
self.main()
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")
text_banner(text="Repo")
if name is None:
name = self.prompts.prompt(
name = prompts.prompt(
message="GitHub Repo Name",
instruction="e.g., spoon-knife",
style=CUSTOM_STYLE,
)
if owner is None:
owner = self.prompts.prompt(
owner = prompts.prompt(
message="GitHub Repo Owner",
instruction="e.g., octocat",
style=CUSTOM_STYLE,
qmark="@",
)
clear_screen()
banner(text=name)
text_banner(text=f"{owner}/{name}")
option = q.select(
"What would you like to do?",
"What would you like to do/get?",
choices=[
q.Choice(
title="Change repo name",
title="Change Repo Name",
value="change_repo_name",
description="Update the name of the repo",
),
q.Choice(
title="Change owner name",
title="Change Owner Name",
value="change_owner",
description="Update the name of repo owner",
),
q.Choice(
title="Change both repo and owner names",
title="Change Repo and Owner Names",
value="change_both",
description="Update both the repo and owner names",
),
@@ -462,10 +549,12 @@ class Menus:
description="Repository data",
),
q.Choice(
title="Forks", value="forks", description="Repository forks"
title="Forks",
value="forks",
description="Repository forks",
),
q.Choice(
title="Issue events",
title="Issue Events",
value="issue_events",
description="Issue events",
),
@@ -480,9 +569,15 @@ class Menus:
description="Assignees",
),
q.Choice(
title="Branches", value="branches", description="Branches"
title="Branches",
value="branches",
description="Branches",
),
q.Choice(
title="Tags",
value="tags",
description="Tags",
),
q.Choice(title="tags", value="tags", description="Tags"),
q.Choice(
title="Languages",
value="languages",
@@ -498,22 +593,38 @@ class Menus:
value="subscribers",
description="Users subscribed to this repo",
),
q.Choice(title="Commits", value="commits", description="Commits"),
q.Choice(
title="Comments", value="comments", description="Comments"
title="Commits",
value="commits",
description="Commits",
),
q.Choice(title="Issues", value="issues", description="Issues"),
q.Choice(
title="Releases", value="releases", description="Releases"
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",
title="Labels",
value="labels",
description="Labels",
),
q.Choice(
title="Go Back",
value="back",
description="Go back to previous menu",
shortcut_key="b",
@@ -525,14 +636,17 @@ class Menus:
shortcut_key="q",
),
],
pointer=POINTER,
style=CUSTOM_STYLE,
instruction=INSTRUCTIONS,
pointer=POINTER,
use_shortcuts=True,
).ask()
if option is None:
self.main()
# Handle navigation
if self._handle_navigation(option, self.repo, name, owner):
if self._navigation(option, self.repo, name, owner):
return
# Handle change options
@@ -549,40 +663,64 @@ class Menus:
# 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)
target = f"{owner}/{name}"
data = self._execute_action(repo, option, target)
self.export(data=data, data_type=option, target=target)
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)
except KeyboardInterrupt:
print("\n\nReturning to main menu...")
self.main()
def org(self, name: t.Optional[str] = None):
"""Organization menu for querying org data."""
"""Organisation menu for querying org data."""
set_menu_title(menu_type="org")
clear_screen()
try:
if name is None:
banner(text="org")
name = self.prompts.prompt(
message="GitHub Organization Name",
text_banner(text="Org")
name = prompts.prompt(
message="GitHub Organisation Name",
instruction="e.g, github",
style=CUSTOM_STYLE,
qmark="@",
)
clear_screen()
banner(text=name)
text_banner(text=name)
option = q.select(
"What would you like to do?",
choices=[
q.Choice(
title="Change org name",
title="Change Org Name",
value="change_org",
description="Query a different organization",
description="Query a different organisation",
),
q.Choice(
title="Profile", value="profile", description="Profile data"
title="Profile",
value="profile",
description="Profile data",
),
q.Choice(
title="Repositories",
@@ -592,17 +730,25 @@ class Menus:
q.Choice(
title="Events",
value="events",
description="Organization events",
description="Organisation events",
),
q.Choice(
title="Hooks",
value="hooks",
description="Webhooks",
),
q.Choice(
title="Issues",
value="issues",
description="Issues",
),
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",
description="Organisation members",
),
q.Choice(
title="Back",
title="Go Back",
value="back",
description="Go back to previous menu",
shortcut_key="b",
@@ -616,12 +762,14 @@ class Menus:
],
style=CUSTOM_STYLE,
instruction=INSTRUCTIONS,
pointer=POINTER,
use_shortcuts=True,
).ask()
if option is None:
self.main()
# Handle navigation
if self._handle_navigation(option, self.org, name):
if self._navigation(option, self.org, name):
return
# Handle change org
@@ -632,11 +780,32 @@ class Menus:
# 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)
data = self._execute_action(org, option, name)
self.export(data=data, data_type=option, target=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)
except KeyboardInterrupt:
print("\n\nReturning to main menu...")
self.main()

View File

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

View File

@@ -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"

197
uv.lock generated
View File

@@ -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]]