diff --git a/.gitignore b/.gitignore index 110bbdd..c93f595 100644 --- a/.gitignore +++ b/.gitignore @@ -2,3 +2,9 @@ __pycache__/ .vscode received_events/ *.html +src/.github_token +.codacy/ + + +#Ignore vscode AI rules +.github/instructions/codacy.instructions.md diff --git a/requirements.txt b/requirements.txt index 7fcb1ad..625748f 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,5 +1,5 @@ colorama==0.4.6 PyQt5==5.15.11 PyQt5_sip==12.15.0 -Requests==2.32.3 +Requests==2.33.0 webview==0.1.5 diff --git a/setup.cfg b/setup.cfg new file mode 100644 index 0000000..6ecb098 --- /dev/null +++ b/setup.cfg @@ -0,0 +1,3 @@ +[pydocstyle] +add-select = D203,D213 +ignore = D211,D212 \ No newline at end of file diff --git a/src/core/utils.py b/src/core/utils.py index 89f0a5b..208786d 100644 --- a/src/core/utils.py +++ b/src/core/utils.py @@ -1,11 +1,15 @@ +"""Shared helpers for fetching GitHub data and rendering the HTML report.""" +import base64 import os import platform import requests from colorama import Fore, Style -from requests.exceptions import HTTPError +from requests.exceptions import HTTPError, RequestException -class colors: +class colors: # pylint: disable=invalid-name,too-few-public-methods # noqa: D203,D211 + """ANSI color constants used for terminal output.""" + HEADER = Fore.MAGENTA BLUE = Fore.BLUE CYAN = Fore.CYAN @@ -18,14 +22,43 @@ class colors: def clear(): - os.system("cls" if platform.system() == "Windows" else "clear") + """Clear the terminal screen without spawning a shell.""" + if platform.system() == "Windows": + print("\033[2J\033[H", end="") + else: + print("\033c", end="") + + +def get_auth_headers(): + """Return the Authorization header dict if a GitHub token is set.""" + token = os.environ.get("GITHUB_TOKEN", "").strip() + if token: + return {"Authorization": f"Bearer {token}"} + return {} + + +def fetch_as_data_uri(url): # noqa: D212,D213 + """Fetch a remote image and return it as a base64 data URI. + + Embeds the image directly in the HTML file (needed when the page is served + via file:// and WebKit would otherwise block external requests). + """ + try: + resp = requests.get(url, timeout=15) + resp.raise_for_status() + content_type = resp.headers.get("Content-Type", "image/svg+xml").split(";")[0].strip() + encoded = base64.b64encode(resp.content).decode("ascii") + return f"data:{content_type};base64,{encoded}" + except (RequestException, ValueError, TypeError): + return url # fall back to original URL on any failure def fetch_and_print_data(username): + """Fetch the GitHub user profile and print each field to the terminal.""" print(f"Fetching data for user: {colors.FAIL}{username}{colors.ENDC}") url = f"https://api.github.com/users/{username}" try: - response = requests.get(url, timeout=10) + response = requests.get(url, headers=get_auth_headers(), timeout=10) response.raise_for_status() data = response.json() print(f"\n{colors.WARNING}User Data:{colors.ENDC}") @@ -35,17 +68,20 @@ def fetch_and_print_data(username): except HTTPError as http_err: print(f"{colors.FAIL}HTTP error occurred: {http_err}{colors.ENDC}") - except Exception as err: + except (RequestException, ValueError) as err: print(f"{colors.FAIL}Unexpected error: {err}{colors.ENDC}") -def show_events_and_graphs(urls): +def show_events_and_graphs(): + """Print a confirmation that graphs are available in the report.""" print( f"\nGraphs available in Received Events [{colors.GREEN}✓{colors.ENDC}]" ) -def generate_html_event_row(avatar, login, event_type, repo_name, repo_url, badge_class, action_text): +def generate_html_event_row( # pylint: disable=too-many-arguments,too-many-positional-arguments + avatar, login, repo_name, repo_url, badge_class, action_text): + """Return the HTML markup for a single received-event row.""" return f"""
Avatar of {login} @@ -57,46 +93,7 @@ def generate_html_event_row(avatar, login, event_type, repo_name, repo_url, badg
""" -def create_and_display_html_user_events(username, urls): - events_url = f"https://api.github.com/users/{username}/received_events" - html_path = ".temp/index.html" - - print(f"Generating HTML report... [{colors.GREEN}✓{colors.ENDC}]\n") - - url = f"https://api.github.com/users/{username}" - try: - response = requests.get(url, timeout=10) - response.raise_for_status() - user_data = response.json() - - login = user_data.get("login", "") - name = user_data.get("name", "") - location = user_data.get("location", "") - html_url = user_data.get("html_url", "") - avatar_url = user_data.get("avatar_url", "") - bio = user_data.get("bio", "") - followers = user_data.get("followers", 0) - following = user_data.get("following", 0) - - except HTTPError as http_err: - print(f"{colors.FAIL}HTTP error occurred: {http_err}{colors.ENDC}") - return - except Exception as err: - print(f"{colors.FAIL}Unexpected error: {err}{colors.ENDC}") - return - - os.makedirs(os.path.dirname(html_path), exist_ok=True) - if os.path.exists(html_path): - os.remove(html_path) - - try: - response = requests.get(events_url, timeout=10) - response.raise_for_status() - events = response.json() - - with open(html_path, "w", encoding="utf_8") as f: - f.write(f""" - +HTML_REPORT_TEMPLATE = """ @@ -207,71 +204,147 @@ def create_and_display_html_user_events(username, urls): Username: {login}
About: {bio}
Followers: {followers}, Following: {following}
- Location: {location} + Location: {location}

View Profile
-

Received Events

""") - - for event in events: - event_type = event.get("type") - actor = event.get("actor", {}) - payload = event.get("payload", {}) - repo = event.get("repo", {}) - login = actor.get("login", "") - avatar = actor.get("avatar_url", "") - repo_name = repo.get("name", "") - repo_url = f"https://github.com/{repo_name}" - - badge_class = "" - action_text = "" - - if event_type == "ForkEvent": - badge_class = "text-danger" - action_text = "Forked a repository" - repo_url = payload.get("forkee", {}).get("html_url", "#") - - elif event_type == "WatchEvent": - badge_class = "text-warning" - action_text = "Watch/Starred a repository" - - elif event_type == "CreateEvent": - badge_class = "text-success" - action_text = "Created a repository" - - elif event_type == "PublicEvent": - badge_class = "text-primary" - action_text = "Published a repository" - - elif event_type == "ReleaseEvent": - badge_class = "text-primary" - action_text = "Released a repository" - repo_url = payload.get("release", {}).get("html_url", "#") - - if action_text: # Only write if action_text is set - f.write(generate_html_event_row( - avatar, login, event_type, repo_name, - repo_url, badge_class, action_text)) - - f.write(f"""
+

Received Events

+{events_html} +

Contribution Insights

- Top Languages - GitHub Stats - Streak Stats + Top Languages + GitHub Stats + Streak Stats
-""") +""" + + +EVENT_ACTIONS = { + "WatchEvent": ("text-warning", "Watch/Starred a repository", None), + "CreateEvent": ("text-success", "Created a repository", None), + "PublicEvent": ("text-primary", "Published a repository", None), + "ForkEvent": ("text-danger", "Forked a repository", "forkee"), + "ReleaseEvent": ("text-primary", "Released a repository", "release"), +} + + +def _resolve_event_action(event_type, payload, repo_url): + """Return badge/action text and final URL for a GitHub event.""" + action = EVENT_ACTIONS.get(event_type) + if not action: + return "", "", repo_url + + badge_class, action_text, payload_key = action + if payload_key: + repo_url = payload.get(payload_key, {}).get("html_url", "#") + + return badge_class, action_text, repo_url + + +def _build_events_html(events): + """Return HTML rows for supported received event types.""" + rows = [] + for event in events: + event_type = event.get("type") + actor = event.get("actor", {}) + payload = event.get("payload", {}) + repo = event.get("repo", {}) + login = actor.get("login", "") + avatar = actor.get("avatar_url", "") + repo_name = repo.get("name", "") + repo_url = f"https://github.com/{repo_name}" + badge_class, action_text, repo_url = _resolve_event_action(event_type, payload, repo_url) + if not action_text: + continue + rows.append(generate_html_event_row( + avatar, + login, + repo_name, + repo_url, + badge_class, + action_text, + )) + return "".join(rows) + + +def _build_report_html(user_data, events_html, urls): + """Render the final HTML report document as a single string.""" + return HTML_REPORT_TEMPLATE.format( + login=user_data.get("login", ""), + name=user_data.get("name", ""), + location=user_data.get("location", ""), + html_url=user_data.get("html_url", ""), + avatar_url=user_data.get("avatar_url", ""), + bio=user_data.get("bio", ""), + followers=user_data.get("followers", 0), + following=user_data.get("following", 0), + events_html=events_html, + langs_src=fetch_as_data_uri(urls["mostUsedLanguages"]), + stats_src=fetch_as_data_uri(urls["githubStats"]), + streak_src=fetch_as_data_uri(urls["streakContributionsLS"]), + ) + + +def _fetch_json(url): + """Fetch JSON from a URL and return parsed data, or None on failure.""" + try: + response = requests.get(url, headers=get_auth_headers(), timeout=10) + response.raise_for_status() + return response.json() except HTTPError as http_err: print(f"{colors.FAIL}HTTP error occurred: {http_err}{colors.ENDC}") - except Exception as err: + except (RequestException, ValueError) as err: + print(f"{colors.FAIL}Unexpected error: {err}{colors.ENDC}") + return None + + +def _prepare_output_path(html_path): + """Create output directory and remove any existing report file.""" + os.makedirs(os.path.dirname(html_path), exist_ok=True) + if os.path.exists(html_path): + os.remove(html_path) + + +def _write_report_file(html_path, report_html): + """Write report HTML to disk, returning True on success.""" + try: + with open(html_path, "w", encoding="utf_8") as file_obj: + file_obj.write(report_html) + return True + except OSError as err: print(f"{colors.FAIL}Unexpected error: {err}{colors.ENDC}") + return False + + +def create_and_display_html_user_events(username, urls): + """Build the user's HTML report from profile data and received events.""" + user_url = f"https://api.github.com/users/{username}" + events_url = f"{user_url}/received_events" + html_path = ".temp/index.html" + + print(f"Generating HTML report... [{colors.GREEN}✓{colors.ENDC}]\n") + + user_data = _fetch_json(user_url) + if user_data is None: + return + + events = _fetch_json(events_url) + if events is None: + return + + _prepare_output_path(html_path) + + events_html = _build_events_html(events) + report_html = _build_report_html(user_data, events_html, urls) + _write_report_file(html_path, report_html) diff --git a/src/htmlviewers/linux.py b/src/htmlviewers/linux.py index 47bd51d..9fd676c 100644 --- a/src/htmlviewers/linux.py +++ b/src/htmlviewers/linux.py @@ -1,13 +1,19 @@ -import webview # type: ignore +"""Linux HTML viewer backed by pywebview/WebKit.""" import os import sys +import webview # type: ignore + +# Suppress dconf "no database" warnings from GTK/WebKit +os.environ.setdefault("DCONF_PROFILE", "/dev/null") +# Suppress MESA ZINK (Vulkan-backed OpenGL) errors — fall back to software renderer +os.environ.setdefault("GALLIUM_DRIVER", "llvmpipe") -def showHTMLLinux(): - # Functions & Global Variables +def show_html_linux(): + """Open the generated HTML report in a pywebview window.""" app_name = "GitHubUserDataExtractor - HTML Viewer" html_file = os.path.abspath(os.path.join( - "Data", "ReceivedEvents", "index.html")) + ".temp", "index.html")) # Check if the file exists if not os.path.exists(html_file): diff --git a/src/htmlviewers/win.py b/src/htmlviewers/win.py index b92a9a6..bc8c7aa 100644 --- a/src/htmlviewers/win.py +++ b/src/htmlviewers/win.py @@ -1,12 +1,18 @@ +"""Windows HTML viewer backed by PyQt5 QWebEngineView.""" import sys import os from PyQt5.QtCore import QUrl -from PyQt5.QtWidgets import QApplication, QMainWindow, QVBoxLayout, QWidget, QMessageBox, QDesktopWidget +from PyQt5.QtWidgets import ( + QApplication, QMainWindow, QVBoxLayout, QWidget, QMessageBox, QDesktopWidget +) from PyQt5.QtWebEngineWidgets import QWebEngineView -class HTMLViewer(QMainWindow): +class HTMLViewer(QMainWindow): # pylint: disable=too-few-public-methods # noqa: D203,D211 + """Main window that renders the generated HTML report.""" + def __init__(self, html_path): + """Initialize the viewer window for the given HTML file path.""" super().__init__() self.setWindowTitle("GitHubUserDataExtractor - HTML Viewer") @@ -45,8 +51,8 @@ def center_on_screen(self): self.move(frame_geometry.topLeft()) -def showHTMLWindow(): - """Launches the PyQt5 application to display the HTML content.""" +def show_html_window(): + """Launch the PyQt5 application and display the HTML content.""" app = QApplication(sys.argv) # Absolute path to the HTML file @@ -56,13 +62,16 @@ def showHTMLWindow(): browser = HTMLViewer(html_file_path) browser.show() - # Execute the application and ensure proper exit - sys.exit(app.exec_()) + # Execute the application and capture the exit code so cleanup can run. + exit_code = app.exec_() # Delete the HTML file after closing the viewer try: os.remove(html_file_path) except FileNotFoundError: print(f"Warning: HTML file '{html_file_path}' not found to delete.") - except Exception as e: + except OSError as e: print(f"Error deleting HTML file: {e}") + + # Exit with the same code returned by Qt. + sys.exit(exit_code) diff --git a/src/main.py b/src/main.py index f4a6fd9..8b1ec1a 100644 --- a/src/main.py +++ b/src/main.py @@ -1,16 +1,14 @@ +"""Entry point for the GitHub User Data Extractor CLI.""" import os +import sys import platform import core.utils as session from core.utils import colors -if platform.system() == "Windows": - import htmlviewers.win as windows -else: - import htmlviewers.linux as linux - def display_banner(): - os.system("cls" if platform.system() == "Windows" else "clear") + """Print the application banner and developer credits.""" + session.clear() banner = r''' ____ _ _ _ _ _ _ _ ____ _ / ___(_) |_| | | |_ _| |__ | | | |___ ___ _ __ | _ \ __ _| |_ __ _ @@ -24,40 +22,110 @@ def display_banner(): f"Website : {colors.CYAN}https://quantumbytestudios.in{colors.ENDC}\n") +TOKEN_DIR = os.path.join(os.path.expanduser("~"), ".config", "githubuserdataextractor") +TOKEN_FILE = os.path.join(TOKEN_DIR, "github_token") +LEGACY_TOKEN_FILE = os.path.join(os.path.dirname(__file__), ".github_token") + + +def load_saved_token(): + """Return the GitHub token persisted on disk, or an empty string.""" + try: + with open(TOKEN_FILE, "r", encoding="utf-8") as f: + return f.read().strip() + except FileNotFoundError: + try: + with open(LEGACY_TOKEN_FILE, "r", encoding="utf-8") as f: + token = f.read().strip() + if token: + save_token(token) + return token + except FileNotFoundError: + return "" + + +def save_token(token): + """Persist the supplied GitHub token to the token file.""" + os.makedirs(TOKEN_DIR, exist_ok=True) + with open(TOKEN_FILE, "w", encoding="utf-8") as f: + f.write(token) + + +def prompt_for_token(): + """Load a saved token or prompt the user for a new one.""" + saved = load_saved_token() + if saved: + os.environ["GITHUB_TOKEN"] = saved + print(f"{colors.GREEN}Saved token loaded — " + f"authenticated rate limit active (5,000 req/hr).{colors.ENDC}\n") + return + + token = input( + f"Enter a GitHub Access Token (leave blank to use unauthenticated): {colors.WARNING}" + ).strip() + print(colors.ENDC, end="") + if token: + os.environ["GITHUB_TOKEN"] = token + save_token(token) + print(f"{colors.GREEN}Token set and saved — " + f"authenticated rate limit active (5,000 req/hr).{colors.ENDC}\n") + else: + print(f"{colors.WARNING}No token provided — " + f"unauthenticated rate limit applies (60 req/hr).{colors.ENDC}\n") + + def get_username(): + """Prompt for and return a non-empty GitHub username.""" username = input( f"Enter a GitHub Username: {colors.WARNING}").strip().lower() print(colors.ENDC, end="") if not username: print(f"{colors.RED}Username cannot be empty!{colors.ENDC}") - exit(1) + sys.exit(1) if username == "exit": print(colors.RED + 'Bye.' + colors.ENDC) - exit(0) + sys.exit(0) return username def get_stat_urls(username): + """Return the mapping of stat-card URLs for the given username.""" return { - "mostUsedLanguages": f"https://github-readme-stats.vercel.app/api/top-langs?username={username}&langs_count=8", - "githubStats": f"https://github-readme-stats.vercel.app/api?username={username}&show_icons=true&locale=en", + "mostUsedLanguages": ( + "https://github-profile-summary-cards.vercel.app/api/cards/repos-per-language" + f"?username={username}&theme=dark" + ), + "githubStats": ( + "https://github-profile-summary-cards.vercel.app/api/cards/stats" + f"?username={username}&theme=dark" + ), "streakContributionsLS": f"https://streak-stats.demolab.com/?user={username}", - "contributorGraphOne": f"https://github-readme-activity-graph.vercel.app/graph?username={username}&bg_color=000000&color=ffffff&line=ffffff&point=ffffff&area=true&hide_border=true", - "contributorGraphTwo": f"https://github-profile-summary-cards.vercel.app/api/cards/profile-details?username={username}&theme=dark" + "contributorGraphOne": ( + "https://github-readme-activity-graph.vercel.app/graph" + f"?username={username}" + "&bg_color=000000&color=ffffff&line=ffffff" + "&point=ffffff&area=true&hide_border=true" + ), + "contributorGraphTwo": ( + "https://github-profile-summary-cards.vercel.app/api/cards/profile-details" + f"?username={username}&theme=dark" + ), } def open_html_viewer(): + """Open the generated HTML report in the platform-specific viewer.""" html_file = os.path.join(".temp", "index.html") if platform.system() == "Linux": try: - linux.showHTMLLinux() + from htmlviewers import linux # pylint: disable=import-outside-toplevel + linux.show_html_linux() except ImportError: print( f"{colors.RED}Error: HTMLViewer_Linux module not found.{colors.ENDC}") elif platform.system() == "Windows": try: - windows.showHTMLWindow() + from htmlviewers import win as windows # pylint: disable=import-outside-toplevel + windows.show_html_window() except ImportError: print( f"{colors.RED}Error: HTMLViewer_Windows module not found.{colors.ENDC}") @@ -73,12 +141,14 @@ def open_html_viewer(): def main(): + """Run the full extraction workflow end-to-end.""" display_banner() + prompt_for_token() username = get_username() urls = get_stat_urls(username) session.fetch_and_print_data(username) - session.show_events_and_graphs(urls) + session.show_events_and_graphs() session.create_and_display_html_user_events(username, urls) open_html_viewer()