├── gifos ├── utils │ ├── schemas │ │ ├── __init__.py │ │ ├── github_user_rank.py │ │ ├── ansi_escape.py │ │ ├── user_age.py │ │ ├── imagebb_image.py │ │ └── github_user_stats.py │ ├── __init__.py │ ├── calc_age.py │ ├── upload_imgbb.py │ ├── calc_github_rank.py │ ├── load_config.py │ ├── convert_ansi_escape.py │ └── fetch_github_stats.py ├── fonts │ ├── gohufont-uni-14.pbm │ └── gohufont-uni-14.pil ├── config │ ├── gifos_settings.toml │ └── ansi_escape_colors.toml ├── __init__.py ├── effects │ ├── __init__.py │ ├── text_scramble_effect.py │ └── text_decode_effect.py └── gifos.py ├── docs └── assets │ ├── logo.png │ └── sample.gif ├── CODE_OF_CONDUCT.md ├── pyproject.toml ├── LICENSE ├── CONTRIBUTING.md ├── .gitignore ├── README.md └── poetry.lock /gifos/utils/schemas/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /docs/assets/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/x0rzavi/github-readme-terminal/HEAD/docs/assets/logo.png -------------------------------------------------------------------------------- /docs/assets/sample.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/x0rzavi/github-readme-terminal/HEAD/docs/assets/sample.gif -------------------------------------------------------------------------------- /gifos/fonts/gohufont-uni-14.pbm: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/x0rzavi/github-readme-terminal/HEAD/gifos/fonts/gohufont-uni-14.pbm -------------------------------------------------------------------------------- /gifos/fonts/gohufont-uni-14.pil: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/x0rzavi/github-readme-terminal/HEAD/gifos/fonts/gohufont-uni-14.pil -------------------------------------------------------------------------------- /gifos/config/gifos_settings.toml: -------------------------------------------------------------------------------- 1 | [general] 2 | debug = false 3 | cursor = "_" 4 | show_cursor = true 5 | blink_cursor = true 6 | user_name = "x0rzavi" # for prompt 7 | fps = 15 8 | color_scheme = "yoru" 9 | loop_count = 0 # infinite loop 10 | 11 | [files] 12 | frame_base_name = "frame_" 13 | frame_folder_name = "frames" 14 | output_gif_name = "output" 15 | -------------------------------------------------------------------------------- /gifos/__init__.py: -------------------------------------------------------------------------------- 1 | """Gifos package. 2 | 3 | This package contains the Terminal class, the effects module, and the utils module. 4 | 5 | The Terminal class is used to create and manipulate a terminal-like object. 6 | 7 | The effects module contains various effects that can be applied to the Terminal object. 8 | 9 | The utils module contains various utility functions that can be used. 10 | """ 11 | 12 | from gifos.gifos import Terminal 13 | from gifos import effects 14 | from gifos import utils 15 | 16 | __all__ = ["Terminal", "effects", "utils"] 17 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | ## GitHub ReadME Terminal Code of Conduct 2 | 3 | A Python project that empowers you to create visually stunning and unique GIFs for your GitHub Profile ReadME. Unleash your creativity and make your profile stand out from the rest! 4 | 5 | - Respect and Inclusivity 6 | - No Code Deletion 7 | - Quality Over Quantity 8 | - Respect Others' Contributions 9 | - Collaborative Problem Solving 10 | - No Trolling or Disruptive Behavior 11 | - Attribution and Licensing 12 | - Privacy and Security 13 | - Reporting Violations **(NO spammy contribution will be allowed)** 14 | - Enforcement 15 | -------------------------------------------------------------------------------- /gifos/utils/schemas/github_user_rank.py: -------------------------------------------------------------------------------- 1 | from dataclasses import dataclass 2 | 3 | 4 | @dataclass 5 | class GithubUserRank: 6 | """A class to represent a GitHub user's rank. 7 | 8 | This class represents a GitHub user's rank, which is calculated based on their 9 | statistics. The class has two attributes: `level` and `percentile`. 10 | 11 | Attributes: level: A string that represents the user's rank level. 12 | percentile: A float that represents the user's percentile rank. 13 | """ 14 | 15 | __slots__ = ["level", "percentile"] 16 | level: str 17 | percentile: float 18 | -------------------------------------------------------------------------------- /gifos/utils/__init__.py: -------------------------------------------------------------------------------- 1 | """This module initializes the gifos.utils package and provides access to its functions. 2 | 3 | The gifos.utils package contains utility functions for the gifos application. These 4 | functions include `calc_age`, `calc_github_rank`, `fetch_github_stats`, and 5 | `upload_imgbb`. 6 | """ 7 | 8 | from gifos.utils.calc_age import calc_age 9 | from gifos.utils.calc_github_rank import calc_github_rank 10 | from gifos.utils.fetch_github_stats import fetch_github_stats 11 | from gifos.utils.upload_imgbb import upload_imgbb 12 | 13 | __all__ = ["calc_age", "calc_github_rank", "fetch_github_stats", "upload_imgbb"] 14 | -------------------------------------------------------------------------------- /gifos/utils/schemas/ansi_escape.py: -------------------------------------------------------------------------------- 1 | from dataclasses import dataclass 2 | 3 | 4 | @dataclass 5 | class AnsiEscape: 6 | """A class to represent an ANSI escape sequence. 7 | 8 | This class represents an ANSI escape sequence, which is used to control the 9 | formatting, color, and other output options on text terminals. The class has two 10 | attributes: `data` and `oper`. 11 | 12 | Attributes: data: A string that represents the data of the escape sequence. 13 | oper: A string that represents the operation of the escape sequence. 14 | """ 15 | 16 | __slots__ = ["data", "oper"] 17 | data: str 18 | oper: str 19 | -------------------------------------------------------------------------------- /gifos/utils/schemas/user_age.py: -------------------------------------------------------------------------------- 1 | from dataclasses import dataclass 2 | 3 | 4 | @dataclass 5 | class UserAge: 6 | """A class to represent a user's age. 7 | 8 | This class represents a user's age in years, months, and days. 9 | 10 | Attributes: 11 | years: An integer that represents the number of full years of the user's age. 12 | months: An integer that represents the number of full months of the user's age, not included in the years. 13 | days: An integer that represents the number of days of the user's age, not included in the years and months. 14 | """ 15 | 16 | __slots__ = ["years", "months", "days"] 17 | years: int 18 | months: int 19 | days: int 20 | -------------------------------------------------------------------------------- /gifos/effects/__init__.py: -------------------------------------------------------------------------------- 1 | """This module initializes the gifos.effects package and provides access to its 2 | functions. 3 | 4 | The gifos.effects package contains functions for generating text effects. 5 | These functions include `text_decode_effect_lines` and `text_scramble_effect_lines`. 6 | 7 | Functions: 8 | text_decode_effect_lines: Generate a list of text lines with a decoding effect. 9 | text_scramble_effect_lines: Generate a list of text lines with a scramble effect. 10 | """ 11 | 12 | from gifos.effects.text_decode_effect import text_decode_effect_lines 13 | from gifos.effects.text_scramble_effect import text_scramble_effect_lines 14 | 15 | __all__ = ["text_decode_effect_lines", "text_scramble_effect_lines"] 16 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [tool.poetry] 2 | name = "github-readme-terminal" 3 | version = "0.0.3" 4 | description = "✨ Elevate your GitHub Profile ReadMe with Minimalistic Retro Terminal GIFs 🚀" 5 | authors = ["Avishek Sen "] 6 | license = "MIT" 7 | readme = "README.md" 8 | repository = "https://github.com/x0rzavi/github-readme-terminal" 9 | packages = [ 10 | { include = "gifos" } 11 | ] 12 | 13 | [tool.poetry.urls] 14 | "Bug Tracker" = "https://github.com/x0rzavi/github-readme-terminal/issues" 15 | 16 | [tool.poetry.dependencies] 17 | python = "^3.9" 18 | tomli = "^2.0.1" 19 | Pillow = "^10.1.0" 20 | requests = "^2.31.0" 21 | icecream = "^2.1.3" 22 | python-dateutil = "^2.8.2" 23 | python-dotenv = "^1.0.0" 24 | 25 | [tool.poetry.group.docs.dependencies] 26 | docformatter = "^1.7.5" 27 | 28 | [build-system] 29 | requires = ["poetry-core"] 30 | build-backend = "poetry.core.masonry.api" 31 | -------------------------------------------------------------------------------- /gifos/utils/calc_age.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime 2 | from dateutil.relativedelta import relativedelta 3 | 4 | from gifos.utils.schemas.user_age import UserAge 5 | 6 | """This module contains a utility function for calculating a person's age.""" 7 | 8 | 9 | def calc_age(day: int, month: int, year: int) -> UserAge: 10 | """Calculate the age of a person given their birth date. 11 | 12 | :param day: The day of the month the person was born (1-31). 13 | :type day: int 14 | :param month: The month the person was born (1-12). 15 | :type month: int 16 | :param year: The year the person was born. 17 | :type year: int 18 | :return: An object containing the person's age in years, months, and days. 19 | :rtype: UserAge 20 | """ 21 | birth_date = datetime(year, month, day) 22 | today = datetime.today() 23 | age = relativedelta(today, birth_date) 24 | return UserAge(age.years, age.months, age.days) 25 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 Avishek Sen 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /gifos/utils/schemas/imagebb_image.py: -------------------------------------------------------------------------------- 1 | from dataclasses import dataclass 2 | 3 | 4 | @dataclass 5 | class ImgbbImage: 6 | """A class to represent an image uploaded to ImgBB. 7 | 8 | This class represents an image uploaded to ImgBB. 9 | 10 | Attributes: 11 | id: A string that represents the image's ID on ImgBB. 12 | url: A string that represents the image's URL on ImgBB. 13 | delete_url: A string that represents the URL to delete the image from ImgBB. 14 | file_name: A string that represents the name of the image file. 15 | expiration: A string that represents the expiration time of the image. 16 | size: A string that represents the size of the image. 17 | mime: A string that represents the MIME type of the image. 18 | extension: A string that represents the extension of the image file. 19 | """ 20 | 21 | __slots__ = [ 22 | "id", 23 | "url", 24 | "delete_url", 25 | "file_name", 26 | "expiration", 27 | "size", 28 | "mime", 29 | "extension", 30 | ] 31 | id: str 32 | url: str 33 | delete_url: str 34 | file_name: str 35 | expiration: str 36 | size: str 37 | mime: str 38 | extension: str 39 | -------------------------------------------------------------------------------- /gifos/utils/schemas/github_user_stats.py: -------------------------------------------------------------------------------- 1 | from dataclasses import dataclass 2 | 3 | from gifos.utils.schemas.github_user_rank import GithubUserRank 4 | 5 | 6 | @dataclass 7 | class GithubUserStats: 8 | """A class to represent a GitHub user's statistics. 9 | 10 | This class represents a GitHub user's statistics. 11 | 12 | Attributes: 13 | account_name: A string that represents the user's account name. 14 | total_followers: An integer that represents the total number of followers the user has. 15 | total_stargazers: An integer that represents the total number of stargazers the user has. 16 | total_issues: An integer that represents the total number of issues the user has opened. 17 | total_commits_all_time: An integer that represents the total number of commits the user has made all time. 18 | total_commits_last_year: An integer that represents the total number of commits the user has made in the last year. 19 | total_pull_requests_made: An integer that represents the total number of pull requests the user has made. 20 | total_pull_requests_merged: An integer that represents the total number of the user's pull requests that have been merged. 21 | pull_requests_merge_percentage: A float that represents the percentage of the user's pull requests that have been merged. 22 | total_pull_requests_reviewed: An integer that represents the total number of pull requests the user has reviewed. 23 | total_repo_contributions: An integer that represents the total number of repositories the user has contributed to. 24 | languages_sorted: A list of tuples that represents the user's most used languages, sorted by usage. Each tuple contains a language name and a usage percentage. 25 | user_rank: A `GithubUserRank` object that represents the user's GitHub rank. 26 | """ 27 | 28 | __slots__ = [ 29 | "account_name", 30 | "total_followers", 31 | "total_stargazers", 32 | "total_issues", 33 | "total_commits_all_time", 34 | "total_commits_last_year", 35 | "total_pull_requests_made", 36 | "total_pull_requests_merged", 37 | "pull_requests_merge_percentage", 38 | "total_pull_requests_reviewed", 39 | "total_repo_contributions", 40 | "languages_sorted", 41 | "user_rank", 42 | ] 43 | account_name: str 44 | total_followers: int 45 | total_stargazers: int 46 | total_issues: int 47 | total_commits_all_time: int 48 | total_commits_last_year: int 49 | total_pull_requests_made: int 50 | total_pull_requests_merged: int 51 | pull_requests_merge_percentage: float 52 | total_pull_requests_reviewed: int 53 | total_repo_contributions: int 54 | languages_sorted: list 55 | user_rank: GithubUserRank 56 | -------------------------------------------------------------------------------- /gifos/utils/upload_imgbb.py: -------------------------------------------------------------------------------- 1 | from base64 import b64encode 2 | import os 3 | import requests 4 | import sys 5 | 6 | from dotenv import load_dotenv 7 | 8 | from gifos.utils.load_config import gifos_settings 9 | from gifos.utils.schemas.imagebb_image import ImgbbImage 10 | 11 | """This module contains a function for uploading an image to ImgBB.""" 12 | 13 | load_dotenv() 14 | IMGBB_API_KEY = os.getenv("IMGBB_API_KEY") 15 | ENDPOINT = "https://api.imgbb.com/1/upload" 16 | 17 | 18 | def upload_imgbb(file_name: str, expiration: int = None) -> ImgbbImage: 19 | """Upload an image to ImgBB. 20 | 21 | This function uploads an image to ImgBB using the ImgBB API. The function reads the 22 | image file, encodes it in base64, and sends a POST request to the ImgBB API. The 23 | function uses the `IMGBB_API_KEY` environment variable for authentication and the 24 | `ENDPOINT` constant for the API endpoint. If the `debug` configuration value is 25 | True, the function sets the image expiration time to 10 minutes. 26 | 27 | :param file_name: The name of the image file to upload. 28 | :type file_name: str 29 | :param expiration: The expiration time for the image in seconds. If the `debug` 30 | configuration value is True, this parameter is ignored and the expiration time 31 | is set to 10 minutes. The value must be between 60 and 15552000 (6 months) if 32 | provided. 33 | :type expiration: int, optional 34 | :return: An `ImgbbImage` object containing the uploaded image's information if the 35 | upload is successful, otherwise None. 36 | :rtype: ImgbbImage or None 37 | """ 38 | if not IMGBB_API_KEY: 39 | print("ERROR: Please provide IMGBB_API_KEY") 40 | sys.exit(1) 41 | 42 | if gifos_settings.get("general", {}).get("debug"): 43 | expiration = 600 44 | print("INFO: Debugging is true Setting expiration to 10min") 45 | else: 46 | if expiration is None: 47 | pass 48 | elif expiration < 60 or expiration > 15552000: 49 | raise ValueError 50 | 51 | with open(file_name, "rb") as image: 52 | image_name = image.name 53 | image_base64 = b64encode(image.read()) 54 | 55 | data = { 56 | "key": IMGBB_API_KEY, 57 | "image": image_base64, 58 | "name": image_name, 59 | } 60 | if expiration: 61 | data["expiration"] = expiration 62 | 63 | response = requests.post(ENDPOINT, data) 64 | if response.status_code == 200: 65 | json_obj = response.json() 66 | return ImgbbImage( 67 | id=json_obj["data"]["id"], 68 | url=json_obj["data"]["url"], 69 | delete_url=json_obj["data"]["delete_url"], 70 | file_name=json_obj["data"]["image"]["filename"], 71 | expiration=json_obj["data"]["expiration"], 72 | size=json_obj["data"]["size"], 73 | mime=json_obj["data"]["image"]["mime"], 74 | extension=json_obj["data"]["image"]["extension"], 75 | ) 76 | else: 77 | print(f"ERROR: {response.status_code}") 78 | return None 79 | -------------------------------------------------------------------------------- /gifos/utils/calc_github_rank.py: -------------------------------------------------------------------------------- 1 | # Reference: https://github.com/anuraghazra/github-readme-stats/blob/23472f40e81170ba452c38a99abc674db0000ce6/src/calculateRank.js 2 | from gifos.utils.schemas.github_user_rank import GithubUserRank 3 | 4 | """This module contains a utility function for calculating a GitHub user's rank.""" 5 | 6 | 7 | def exponential_cdf(x): 8 | return 1 - 2**-x 9 | 10 | 11 | def log_normal_cdf(x): 12 | return x / (1 + x) 13 | 14 | 15 | def calc_github_rank( 16 | all_commits: bool, 17 | commits: int, 18 | prs: int, 19 | issues: int, 20 | reviews: int, 21 | stars: int, 22 | followers: int, 23 | ) -> GithubUserRank: 24 | """Calculate the GitHub rank of a user based on their activity. 25 | 26 | The rank is calculated using a weighted sum of various activity metrics, including 27 | commits, pull requests, issues, reviews, stars, and followers. Each metric is 28 | normalized using a cumulative distribution function (either exponential or log- 29 | normal) before being weighted and summed. 30 | 31 | :param all_commits: Whether to consider all commits or only those in the last year. 32 | :type all_commits: bool 33 | :param commits: The number of commits the user has made. 34 | :type commits: int 35 | :param prs: The number of pull requests the user has made. 36 | :type prs: int 37 | :param issues: The number of issues the user has opened. 38 | :type issues: int 39 | :param reviews: The number of reviews the user has made. 40 | :type reviews: int 41 | :param stars: The number of stars the user's repositories have received. 42 | :type stars: int 43 | :param followers: The number of followers the user has. 44 | :type followers: int 45 | :return: The user's GitHub rank and percentile. 46 | :rtype: GithubUserRank 47 | """ 48 | COMMITS_MEDIAN = 1000 if all_commits else 250 49 | COMMITS_WEIGHT = 2 50 | PRS_MEDIAN = 50 51 | PRS_WEIGHT = 3 52 | ISSUES_MEDIAN = 25 53 | ISSUES_WEIGHT = 1 54 | REVIEWS_MEDIAN = 2 55 | REVIEWS_WEIGHT = 1 56 | STARS_MEDIAN = 50 57 | STARS_WEIGHT = 4 58 | FOLLOWERS_MEDIAN = 10 59 | FOLLOWERS_WEIGHT = 1 60 | TOTAL_WEIGHT = ( 61 | COMMITS_WEIGHT 62 | + PRS_WEIGHT 63 | + ISSUES_WEIGHT 64 | + REVIEWS_WEIGHT 65 | + STARS_WEIGHT 66 | + FOLLOWERS_WEIGHT 67 | ) 68 | 69 | THRESHOLDS = [1, 12.5, 25, 37.5, 50, 62.5, 75, 87.5, 100] 70 | LEVELS = ["S", "A+", "A", "A-", "B+", "B", "B-", "C+", "C"] 71 | rank = ( 72 | 1 73 | - ( 74 | COMMITS_WEIGHT * exponential_cdf(commits / COMMITS_MEDIAN) 75 | + PRS_WEIGHT * exponential_cdf(prs / PRS_MEDIAN) 76 | + ISSUES_WEIGHT * exponential_cdf(issues / ISSUES_MEDIAN) 77 | + REVIEWS_WEIGHT * exponential_cdf(reviews / REVIEWS_MEDIAN) 78 | + STARS_WEIGHT * log_normal_cdf(stars / STARS_MEDIAN) 79 | + FOLLOWERS_WEIGHT * log_normal_cdf(followers / FOLLOWERS_MEDIAN) 80 | ) 81 | / TOTAL_WEIGHT 82 | ) 83 | 84 | level = LEVELS[ 85 | next( 86 | (i for i, t in enumerate(THRESHOLDS) if rank * 100 <= t), 87 | len(LEVELS) - 1, 88 | ) 89 | ] 90 | percentile = round(rank * 100, 2) 91 | return GithubUserRank(level, percentile) 92 | -------------------------------------------------------------------------------- /gifos/effects/text_scramble_effect.py: -------------------------------------------------------------------------------- 1 | import random 2 | import string 3 | 4 | upper_case = string.ascii_uppercase 5 | lower_case = string.ascii_lowercase 6 | special_chars = string.punctuation 7 | 8 | 9 | def text_scramble_effect_lines( 10 | input_text: str, 11 | multiplier: int, 12 | only_upper: bool = False, 13 | include_special: bool = True, 14 | ) -> list: 15 | """Generate a list of text lines with a scramble effect. 16 | 17 | This function generates a list of text lines that simulate a scramble effect. The 18 | function takes an input text and a multiplier as parameters. The multiplier 19 | determines the number of times each line is repeated in the output. The function 20 | randomly replaces characters in the input text with characters from a list of upper 21 | case, lower case, and special characters to create the scramble effect. 22 | 23 | :param input_text: The text to apply the scramble effect to. 24 | :type input_text: str 25 | :param multiplier: The number of times each line is repeated in the output. 26 | :type multiplier: int 27 | :param only_upper: A boolean that determines whether to only use upper case 28 | characters for the scramble effect. Defaults to False. 29 | :type only_upper: bool, optional 30 | :param include_special: A boolean that determines whether to include special 31 | characters in the scramble effect. Defaults to True. 32 | :type include_special: bool, optional 33 | :return: A list of text lines with the scramble effect. 34 | :rtype: list 35 | """ 36 | lines_list = list() 37 | if only_upper: 38 | total_chars = upper_case 39 | else: 40 | total_chars = upper_case + lower_case 41 | if include_special: 42 | total_chars += special_chars 43 | 44 | for i in range(len(input_text) * multiplier): # no of lines 45 | output_text = "" 46 | for j in range(len(input_text)): # for each char 47 | if i < multiplier or input_text[j] == " ": 48 | output_text += ( 49 | " " if input_text[j] == " " else random.choice(total_chars) 50 | ) 51 | elif i // multiplier >= j: 52 | output_text += input_text[j] 53 | else: 54 | output_text += random.choice(total_chars) 55 | lines_list.append(output_text) 56 | 57 | def random_replace(count: int = 1): 58 | num_chars_to_replace = 2 59 | for _ in range(count): 60 | for _ in range(multiplier): # randomly change consecutive chars 61 | char_index = random.randint(0, len(input_text) - num_chars_to_replace) 62 | # while " " in input_text[char_index : char_index + num_chars_to_replace]: # only choose if space not in between 63 | # char_index = random.randint(0, len(input_text) - num_chars_to_replace) 64 | output_text = ( 65 | input_text[:char_index] 66 | + "".join( 67 | random.choice(total_chars) for _ in range(num_chars_to_replace) 68 | ) 69 | + input_text[char_index + num_chars_to_replace :] 70 | ) 71 | lines_list.append(output_text) 72 | 73 | for _ in range(multiplier): 74 | lines_list.append(input_text) 75 | 76 | random_replace(2) 77 | 78 | return lines_list 79 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 |

✨ Contributors Guide ✨

2 |

Welcome to GitHub-ReadME-Terminal project! 😍
We appreciate your interest in contributing.😊
This guide will help you get started with the project and make your first contribution.

3 | 4 | ![Line](https://user-images.githubusercontent.com/85225156/171937799-8fc9e255-9889-4642-9c92-6df85fb86e82.gif) 5 | 6 | ## What you can Contribute? 7 | 8 | **🐞Bug Fixing :** 9 | Contributors can help by reviewing and confirming reported issues. This includes verifying bugs, providing additional details, and prioritizing tasks for the development team. 10 | 11 | **💡Feature Requests:** 12 | Contribute your innovative ideas to enhance our project's functionality by suggesting new features under this section! 13 | 14 | **📝Documentation Updates:** 15 | Help us improve user experience and clarity by contributing to our documentation section with updates, clarifications, or additional information! 16 | 17 | **✨Code Contributions :** 18 | Contributors can enhance the project by implementing new features or improvements suggested by users. This involves understanding requirements, designing solutions, and extending the functionality of this Github-Readme-terminal. 🚀 19 | 20 | ![Line](https://user-images.githubusercontent.com/85225156/171937799-8fc9e255-9889-4642-9c92-6df85fb86e82.gif) 21 | 22 | ## How to Contribute? 23 | - Drop a Star ⭐ in this repo 24 | - Take a look at the existing [Issues](https://github.com/x0rzavi/github-readme-terminal/issues). 25 | - Fork the Repo & create a branch for any issue that you are working on and commit your work. 26 | - At first raise an issue in which you want to work 27 | - Then after assigning only then work on that issue & make a PR 28 | - Create a [**Pull Request**](https://github.com/x0rzavi/github-readme-terminal/pulls), which will be promptly reviewed and given suggestions for improvements by the community. 29 | - **REMINDER: Don't raise more than 1 `Issue` at a time** 30 | - **IMPORTANT: Don't make any type of `Pull Request` until & unless you get assigned to an `Issue`** 31 | - Add screenshots or screen captures to your `Pull Request` to help us understand the effects of the changes that are included in your commits. 32 | 33 | Thank you for your contribution!! 34 | 35 | ![Line](https://user-images.githubusercontent.com/85225156/171937799-8fc9e255-9889-4642-9c92-6df85fb86e82.gif) 36 | 37 |

Need more help? 🤔

38 |

39 | You can refer to the following articles on basics of Git and Github and also contact the Project Mentors, in case you are stuck:
40 | How to create a Issue
41 | Forking a Repo
42 | Cloning a Repo
43 | How to create a Pull Request
44 | Getting started with Git and GitHub
45 |

46 | 47 |

Tip from us 😇

48 |

It always takes time to understand and learn. So, don't worry at all. We know you have got this! 💪

49 |

Show some  ❤️  by  🌟  this repository!

50 | -------------------------------------------------------------------------------- /gifos/utils/load_config.py: -------------------------------------------------------------------------------- 1 | import os 2 | from pathlib import Path 3 | 4 | try: 5 | import tomllib 6 | except ModuleNotFoundError: 7 | import tomli as tomllib 8 | 9 | """This module contains a function for loading a TOML configuration file or 10 | updating configuration with environment variables.""" 11 | 12 | 13 | def load_toml(file_name: str) -> dict: 14 | """Load a TOML configuration file. 15 | 16 | This function reads a TOML configuration file and returns a dictionary 17 | containing the configuration values. The function first looks for the configuration file in a 18 | default location and loads the configuration values. If a user configuration file 19 | exists in the user's home directory, the function reads it and updates the 20 | configuration values accordingly. The function also updates the configuration values 21 | with any matching environment variables. 22 | 23 | :param file_name: The name of the configuration file to load. 24 | :type file_name: str 25 | :return: A dictionary containing the configuration values. 26 | :rtype: dict 27 | """ 28 | 29 | def __update_config_with_env_vars(config, prefix="GIFOS"): 30 | for key, value in config.items(): 31 | if isinstance(value, dict): 32 | __update_config_with_env_vars(value, f"{prefix}_{key.upper()}") 33 | else: 34 | env_var_name = f"{prefix}_{key.upper()}" 35 | env_var_value = os.getenv(env_var_name) 36 | if env_var_value is not None: 37 | if env_var_value.lower() in [ 38 | "true", 39 | "false", 40 | ]: # check if the env var is a boolean 41 | env_var_value = ( 42 | env_var_value.lower() == "true" 43 | ) # convert to boolean 44 | else: 45 | try: 46 | env_var_value = int( 47 | env_var_value 48 | ) # convert string values to int 49 | except ValueError: 50 | pass 51 | config[key] = env_var_value 52 | print( 53 | f"INFO: Config updated from environment variable: {env_var_name}={env_var_value}" 54 | ) 55 | 56 | def __deep_merge_dicts(dict1, dict2): 57 | for key in dict2: 58 | if key in dict1: 59 | if isinstance(dict1[key], dict) and isinstance(dict2[key], dict): 60 | __deep_merge_dicts(dict1[key], dict2[key]) 61 | else: 62 | dict1[key] = dict2[key] # Overwrite value 63 | else: 64 | dict1[key] = dict2[key] # Add new value 65 | return dict1 66 | 67 | def_config_file = ( 68 | Path(__file__).parents[1] / "config" / file_name 69 | ) # default config path 70 | user_config_file = Path.home() / ".config" / "gifos" / file_name # user config path 71 | 72 | with def_config_file.open(mode="rb") as def_fp: 73 | config = tomllib.load(def_fp) 74 | 75 | if user_config_file.exists(): 76 | with user_config_file.open(mode="rb") as user_fp: 77 | user_config = tomllib.load(user_fp) 78 | config = __deep_merge_dicts( 79 | config, user_config 80 | ) # override with user config 81 | print(f"INFO: User config_file: {file_name} loaded") 82 | else: 83 | print(f"INFO: Default config_file: {file_name} loaded") 84 | 85 | __update_config_with_env_vars(config) # override with environment variables 86 | return config 87 | 88 | 89 | gifos_settings = load_toml("gifos_settings.toml") 90 | ansi_escape_colors = load_toml("ansi_escape_colors.toml") 91 | 92 | __all__ = ["gifos_settings", "ansi_escape_colors"] 93 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | wheels/ 23 | share/python-wheels/ 24 | *.egg-info/ 25 | .installed.cfg 26 | *.egg 27 | MANIFEST 28 | 29 | # PyInstaller 30 | # Usually these files are written by a python script from a template 31 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 32 | *.manifest 33 | *.spec 34 | 35 | # Installer logs 36 | pip-log.txt 37 | pip-delete-this-directory.txt 38 | 39 | # Unit test / coverage reports 40 | htmlcov/ 41 | .tox/ 42 | .nox/ 43 | .coverage 44 | .coverage.* 45 | .cache 46 | nosetests.xml 47 | coverage.xml 48 | *.cover 49 | *.py,cover 50 | .hypothesis/ 51 | .pytest_cache/ 52 | cover/ 53 | 54 | # Translations 55 | *.mo 56 | *.pot 57 | 58 | # Django stuff: 59 | *.log 60 | local_settings.py 61 | db.sqlite3 62 | db.sqlite3-journal 63 | 64 | # Flask stuff: 65 | instance/ 66 | .webassets-cache 67 | 68 | # Scrapy stuff: 69 | .scrapy 70 | 71 | # Sphinx documentation 72 | docs/_build/ 73 | 74 | # PyBuilder 75 | .pybuilder/ 76 | target/ 77 | 78 | # Jupyter Notebook 79 | .ipynb_checkpoints 80 | 81 | # IPython 82 | profile_default/ 83 | ipython_config.py 84 | 85 | # pyenv 86 | # For a library or package, you might want to ignore these files since the code is 87 | # intended to run in multiple environments; otherwise, check them in: 88 | # .python-version 89 | 90 | # pipenv 91 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 92 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 93 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 94 | # install all needed dependencies. 95 | #Pipfile.lock 96 | 97 | # poetry 98 | # Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. 99 | # This is especially recommended for binary packages to ensure reproducibility, and is more 100 | # commonly ignored for libraries. 101 | # https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control 102 | #poetry.lock 103 | 104 | # pdm 105 | # Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. 106 | #pdm.lock 107 | # pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it 108 | # in version control. 109 | # https://pdm.fming.dev/#use-with-ide 110 | .pdm.toml 111 | 112 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm 113 | __pypackages__/ 114 | 115 | # Celery stuff 116 | celerybeat-schedule 117 | celerybeat.pid 118 | 119 | # SageMath parsed files 120 | *.sage.py 121 | 122 | # Environments 123 | .env 124 | .venv 125 | env/ 126 | venv/ 127 | ENV/ 128 | env.bak/ 129 | venv.bak/ 130 | 131 | # Spyder project settings 132 | .spyderproject 133 | .spyproject 134 | 135 | # Rope project settings 136 | .ropeproject 137 | 138 | # mkdocs documentation 139 | /site 140 | 141 | # mypy 142 | .mypy_cache/ 143 | .dmypy.json 144 | dmypy.json 145 | 146 | # Pyre type checker 147 | .pyre/ 148 | 149 | # pytype static type analyzer 150 | .pytype/ 151 | 152 | # Cython debug symbols 153 | cython_debug/ 154 | 155 | # PyCharm 156 | # JetBrains specific template is maintained in a separate JetBrains.gitignore that can 157 | # be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore 158 | # and can be added to the global gitignore or merged into this file. For a more nuclear 159 | # option (not recommended) you can uncomment the following to ignore the entire idea folder. 160 | #.idea/ 161 | 162 | # Miscellaneous 163 | *.png 164 | *.gif 165 | temp* 166 | frame* 167 | fonts/ 168 | .vscode 169 | !docs/** -------------------------------------------------------------------------------- /gifos/effects/text_decode_effect.py: -------------------------------------------------------------------------------- 1 | # TODO 2 | # [] resource intensive when 3 chars need to be chosen from large pool 3 | import random 4 | 5 | 6 | def generate_pattern_lines( 7 | output_text_len: int, 8 | num_chars: int, 9 | count: int, 10 | ) -> list: 11 | chars_list = ["<", ">", "/", "*", " "] 12 | unwanted_patterns = None 13 | # unwanted_patterns = [" ", "**", "<*>", "/ /"] 14 | output_text_lines = list() 15 | prev_output_text = "" 16 | 17 | if output_text_len < 5: 18 | return output_text_lines 19 | if num_chars > output_text_len - 1: # need minimum 1 for space 20 | num_chars = output_text_len - 1 21 | 22 | for _ in range(count): 23 | while True: 24 | output_text = "".join( 25 | random.choice(chars_list) for _ in range(output_text_len) 26 | ) 27 | if unwanted_patterns and any( 28 | seq in output_text for seq in unwanted_patterns 29 | ): 30 | continue 31 | num_non_space_chars = len(output_text) - output_text.count(" ") 32 | 33 | if ( 34 | (output_text.count("*") <= 2) 35 | and (output_text.count(" ") >= 1) 36 | and (output_text.count("/") >= 1) 37 | and (output_text.count("<") <= output_text_len) 38 | and (output_text.count(">") <= output_text_len) 39 | and ( 40 | prev_output_text[0:3] in output_text 41 | or prev_output_text[1:4] in output_text 42 | ) # generate similar to last 43 | and num_non_space_chars == num_chars 44 | ): 45 | prev_output_text = output_text 46 | output_text_lines.append(output_text) 47 | break 48 | 49 | return output_text_lines 50 | 51 | 52 | def text_decode_effect_lines(input_text: str, multiplier: int) -> list: 53 | """Generate a list of text lines with a decoding effect. 54 | 55 | This function generates a list of text lines that simulate a decoding effect. The 56 | function takes an input text and a multiplier as parameters. The multiplier 57 | determines the number of times each line is repeated in the output. The function 58 | randomly replaces characters in the input text with characters from a list to create 59 | the decoding effect. 60 | 61 | :param input_text: The text to apply the decoding effect to. 62 | :type input_text: str 63 | :param multiplier: The number of times each line is repeated in the output. 64 | :type multiplier: int 65 | :return: A list of text lines with the decoding effect. 66 | :rtype: list 67 | """ 68 | lines_list = list() 69 | chars_list = ["<", ">", "/", "*", " "] 70 | 71 | def random_replace(count: int = 1): 72 | num_chars_to_replace = 2 73 | for _ in range(count): 74 | for _ in range(multiplier): # randomly change consecutive chars 75 | char_index = random.randint(0, len(input_text) - num_chars_to_replace) 76 | # while " " in input_text[char_index : char_index + num_chars_to_replace]: # only choose if space not in between 77 | # char_index = random.randint(0, len(input_text) - num_chars_to_replace) 78 | output_text = ( 79 | input_text[:char_index] 80 | + "".join( 81 | random.choice(chars_list) for _ in range(num_chars_to_replace) 82 | ) 83 | + input_text[char_index + num_chars_to_replace :] 84 | ) 85 | lines_list.append(output_text) 86 | 87 | for _ in range(multiplier): 88 | lines_list.append(input_text) 89 | 90 | # lines_list += generate_pattern_lines(len(input_text), 3, multiplier) 91 | # lines_list += generate_pattern_lines( 92 | # len(input_text), ((len(input_text) - 3) // 2), multiplier 93 | # ) 94 | # lines_list += generate_pattern_lines( 95 | # len(input_text), ((len(input_text) - 3) // 2) * 2, multiplier 96 | # ) 97 | 98 | for i in range(len(input_text)): # for each char 99 | for _ in range(multiplier): 100 | output_text = generate_pattern_lines(len(input_text), len(input_text), 1) 101 | output_text = input_text[0 : i + 1] + output_text[0][i + 1 :] 102 | lines_list.append(output_text) 103 | 104 | random_replace(2) 105 | 106 | return lines_list 107 | -------------------------------------------------------------------------------- /gifos/utils/convert_ansi_escape.py: -------------------------------------------------------------------------------- 1 | # Colorscheme reference: https://github.com/rxyhn/yoru#art--colorscheme 2 | from gifos.utils.load_config import ansi_escape_colors, gifos_settings 3 | from gifos.utils.schemas.ansi_escape import AnsiEscape 4 | 5 | """This module contains a class `ConvertAnsiEscape` for converting ANSI escape codes to color values.""" 6 | 7 | 8 | class ConvertAnsiEscape: 9 | """A class for converting ANSI escape codes to color values.""" 10 | 11 | __color_scheme = gifos_settings.get("general", {}).get("color_scheme") 12 | 13 | @staticmethod 14 | def __get_color(color_dict, color_name, def_color): 15 | """Get the color value from the color dictionary. 16 | 17 | This method takes a color dictionary, a color name, and a default color as 18 | input. If the color dictionary is indeed a dictionary and contains the color 19 | name, it returns the corresponding color value. Otherwise, it returns the 20 | default color. 21 | 22 | :param color_dict: The color dictionary to get the color value from. 23 | :type color_dict: dict 24 | :param color_name: The name of the color to get. 25 | :type color_name: str 26 | :param def_color: The default color to return if the color name is not in the 27 | color dictionary. 28 | :type def_color: str 29 | :return: The color value corresponding to the color name if it's in the color 30 | dictionary, otherwise the default color. 31 | :rtype: str 32 | """ 33 | return ( 34 | color_dict.get(color_name, def_color) 35 | if isinstance(color_dict, dict) 36 | else def_color 37 | ) 38 | 39 | # fmt: off 40 | ANSI_ESCAPE_MAP_TXT_COLOR = { 41 | # normal color mode 42 | "30": __get_color(ansi_escape_colors.get(__color_scheme, {}).get("normal_colors"), "black", "#232526"), 43 | "31": __get_color(ansi_escape_colors.get(__color_scheme, {}).get("normal_colors"), "red", "#df5b61"), 44 | "32": __get_color(ansi_escape_colors.get(__color_scheme, {}).get("normal_colors"), "green", "#78b892"), 45 | "33": __get_color(ansi_escape_colors.get(__color_scheme, {}).get("normal_colors"), "yellow", "#de8f78"), 46 | "34": __get_color(ansi_escape_colors.get(__color_scheme, {}).get("normal_colors"), "blue", "#6791c9"), 47 | "35": __get_color(ansi_escape_colors.get(__color_scheme, {}).get("normal_colors"), "magenta", "#bc83e3"), 48 | "36": __get_color(ansi_escape_colors.get(__color_scheme, {}).get("normal_colors"), "cyan", "#67afc1"), 49 | "37": __get_color(ansi_escape_colors.get(__color_scheme, {}).get("normal_colors"), "white", "#e4e6e7"), 50 | "39": __get_color(ansi_escape_colors.get(__color_scheme, {}).get("default_colors"), "fg", "#edeff0"), 51 | # bright color mode 52 | "90": __get_color(ansi_escape_colors.get(__color_scheme, {}).get("bright_colors"), "black", "#2c2e2f"), 53 | "91": __get_color(ansi_escape_colors.get(__color_scheme, {}).get("bright_colors"), "red", "#e8646a"), 54 | "92": __get_color(ansi_escape_colors.get(__color_scheme, {}).get("bright_colors"), "green", "#81c19b"), 55 | "93": __get_color(ansi_escape_colors.get(__color_scheme, {}).get("bright_colors"), "yellow", "#e79881"), 56 | "94": __get_color(ansi_escape_colors.get(__color_scheme, {}).get("bright_colors"), "blue", "#709ad2"), 57 | "95": __get_color(ansi_escape_colors.get(__color_scheme, {}).get("bright_colors"), "magenta", "#c58cec"), 58 | "96": __get_color(ansi_escape_colors.get(__color_scheme, {}).get("bright_colors"), "cyan", "#70b8ca"), 59 | "97": __get_color(ansi_escape_colors.get(__color_scheme, {}).get("bright_colors"), "white", "#f2f4f5"), 60 | } 61 | 62 | ANSI_ESCAPE_MAP_BG_COLOR = { 63 | # normal color mode 64 | "40": __get_color(ansi_escape_colors.get(__color_scheme, {}).get("normal_colors"), "black", "#232526"), 65 | "41": __get_color(ansi_escape_colors.get(__color_scheme, {}).get("normal_colors"), "red", "#df5b61"), 66 | "42": __get_color(ansi_escape_colors.get(__color_scheme, {}).get("normal_colors"), "green", "#78b892"), 67 | "43": __get_color(ansi_escape_colors.get(__color_scheme, {}).get("normal_colors"), "yellow", "#de8f78"), 68 | "44": __get_color(ansi_escape_colors.get(__color_scheme, {}).get("normal_colors"), "blue", "#6791c9"), 69 | "45": __get_color(ansi_escape_colors.get(__color_scheme, {}).get("normal_colors"), "magenta", "#bc83e3"), 70 | "46": __get_color(ansi_escape_colors.get(__color_scheme, {}).get("normal_colors"), "cyan", "#67afc1"), 71 | "47": __get_color(ansi_escape_colors.get(__color_scheme, {}).get("normal_colors"), "white", "#e4e6e7"), 72 | "49": __get_color(ansi_escape_colors.get(__color_scheme, {}).get("default_colors"), "bg", "#0c0e0f"), 73 | # bright color mode 74 | "100": __get_color(ansi_escape_colors.get(__color_scheme, {}).get("bright_colors"), "black", "#2c2e2f"), 75 | "101": __get_color(ansi_escape_colors.get(__color_scheme, {}).get("bright_colors"), "red", "#e8646a"), 76 | "102": __get_color(ansi_escape_colors.get(__color_scheme, {}).get("bright_colors"), "green", "#81c19b"), 77 | "103": __get_color(ansi_escape_colors.get(__color_scheme, {}).get("bright_colors"), "yellow", "#e79881"), 78 | "104": __get_color(ansi_escape_colors.get(__color_scheme, {}).get("bright_colors"), "blue", "#709ad2"), 79 | "105": __get_color(ansi_escape_colors.get(__color_scheme, {}).get("bright_colors"), "magenta", "#c58cec"), 80 | "106": __get_color(ansi_escape_colors.get(__color_scheme, {}).get("bright_colors"), "cyan", "#70b8ca"), 81 | "107": __get_color(ansi_escape_colors.get(__color_scheme, {}).get("bright_colors"), "white", "#f2f4f5"), 82 | } 83 | # fmt: on 84 | 85 | @classmethod 86 | def convert(cls, escape_code) -> AnsiEscape: 87 | """Convert an ANSI escape code to a color value. 88 | 89 | This method takes an ANSI escape code as input and returns an `AnsiEscape` 90 | object containing the corresponding color value and operation (text color or 91 | background color). The method uses two dictionaries `ANSI_ESCAPE_MAP_TXT_COLOR` 92 | and `ANSI_ESCAPE_MAP_BG_COLOR` to map ANSI escape codes to color values for text 93 | and background colors respectively. 94 | 95 | :param escape_code: The ANSI escape code to convert. 96 | :type escape_code: str 97 | :return: An `AnsiEscape` object containing the color value and operation if the 98 | escape code is found in the dictionaries, otherwise None. 99 | :rtype: AnsiEscape or None 100 | """ 101 | txt_color = cls.ANSI_ESCAPE_MAP_TXT_COLOR.get(escape_code) 102 | if txt_color: 103 | return AnsiEscape(data=txt_color, oper="txt_color") 104 | 105 | bg_color = cls.ANSI_ESCAPE_MAP_BG_COLOR.get(escape_code) 106 | if bg_color: 107 | return AnsiEscape(data=bg_color, oper="bg_color") 108 | 109 | return None 110 | -------------------------------------------------------------------------------- /gifos/config/ansi_escape_colors.toml: -------------------------------------------------------------------------------- 1 | [yoru] 2 | [yoru.default_colors] 3 | fg = "#edeff0" 4 | bg = "#0c0e0f" 5 | 6 | [yoru.normal_colors] 7 | black = "#232526" 8 | red = "#df5b61" 9 | green = "#78b892" 10 | yellow = "#de8f78" 11 | blue = "#6791c9" 12 | magenta = "#bc83e3" 13 | cyan = "#67afc1" 14 | white = "#e4e6e7" 15 | 16 | [yoru.bright_colors] 17 | black = "#2c2e2f" 18 | red = "#e8646a" 19 | green = "#81c19b" 20 | yellow = "#e79881" 21 | blue = "#709ad2" 22 | magenta = "#c58cec" 23 | cyan = "#70b8ca" 24 | white = "#f2f4f5" 25 | 26 | [gruvbox-dark] 27 | [gruvbox-dark.default_colors] 28 | fg = "#ebdbb2" 29 | bg = "#282828" 30 | 31 | [gruvbox-dark.normal_colors] 32 | black = "#282828" 33 | red = "#cc241d" 34 | green = "#98971a" 35 | yellow = "#d79921" 36 | blue = "#458588" 37 | magenta = "#b16286" 38 | cyan = "#689d6a" 39 | white = "#8ec07c" 40 | 41 | [gruvbox-dark.bright_colors] 42 | black = "#928374" 43 | red = "#fb4934" 44 | green = "#b8bb26" 45 | yellow = "#fabd2f" 46 | blue = "#83a598" 47 | magenta = "#d3869b" 48 | cyan = "#8ec07c" 49 | white = "#ebdbb2" 50 | 51 | [gruvbox-light] 52 | [gruvbox-light.default_colors] 53 | fg = "#3c3836" 54 | bg = "#fbf1c7" 55 | 56 | [gruvbox-light.normal_colors] 57 | black = "#fdf4c1" 58 | red = "#cc241d" 59 | green = "#98971a" 60 | yellow = "#d79921" 61 | blue = "#458588" 62 | magenta = "#b16286" 63 | cyan = "#689d6a" 64 | white = "#7c6f64" 65 | 66 | [gruvbox-light.bright_colors] 67 | black = "#928374" 68 | red = "#9d0006" 69 | green = "#79740e" 70 | yellow = "#b57614" 71 | blue = "#076678" 72 | magenta = "#8f3f71" 73 | cyan = "#427b58" 74 | white = "#3c3836" 75 | 76 | [rose-pine] 77 | [rose-pine.default_colors] 78 | fg = "#e0def4" 79 | bg = "#191724" 80 | 81 | [rose-pine.normal_colors] 82 | black = "#26233a" 83 | red = "#eb6f92" 84 | green = "#31748f" 85 | yellow = "#f6c177" 86 | blue = "#9ccfd8" 87 | magenta = "#c4a7e7" 88 | cyan = "#ebbcba" 89 | white = "#e0def4" 90 | 91 | [rose-pine.bright_colors] 92 | black = "#6e6a86" 93 | red = "#eb6f92" 94 | green = "#31748f" 95 | yellow = "#f6c177" 96 | blue = "#9ccfd8" 97 | magenta = "#c4a7e7" 98 | cyan = "#ebbcba" 99 | white = "#e0def4" 100 | 101 | [dracula] 102 | [dracula.default_colors] 103 | fg = "#F8F8F2" 104 | bg = "#282A36" 105 | 106 | [dracula.normal_colors] 107 | black = "#000000" 108 | red = "#FF5555" 109 | green = "#50FA7B" 110 | yellow = "#F1FA8C" 111 | blue = "#BD93F9" 112 | magenta = "#FF79C6" 113 | cyan = "#8BE9FD" 114 | white = "#BFBFBF" 115 | 116 | [dracula.bright_colors] 117 | black = "#4D4D4D" 118 | red = "#FF6E67" 119 | green = "#5AF78E" 120 | yellow = "#F4F99D" 121 | blue = "#CAA9FA" 122 | magenta = "#FF92D0" 123 | cyan = "#9AEDFE" 124 | white = "#E6E6E6" 125 | 126 | [nord] 127 | [nord.default_colors] 128 | fg = "#D8DEE9" 129 | bg = "#2E3440" 130 | 131 | [nord.normal_colors] 132 | black = "#3B4252" 133 | red = "#BF616A" 134 | green = "#A3BE8C" 135 | yellow = "#EBCB8B" 136 | blue = "#81A1C1" 137 | magenta = "#B48EAD" 138 | cyan = "#88C0D0" 139 | white = "#E5E9F0" 140 | 141 | [nord.bright_colors] 142 | black = "#4C566A" 143 | red = "#BF616A" 144 | green = "#A3BE8C" 145 | yellow = "#EBCB8B" 146 | blue = "#81A1C1" 147 | magenta = "#B48EAD" 148 | cyan = "#8FBCBB" 149 | white = "#ECEFF4" 150 | 151 | [catppuccin-mocha] 152 | [catppuccin-mocha.default_colors] 153 | fg = "#CDD6F4" 154 | bg = "#1E1E2E" 155 | 156 | [catppuccin-mocha.normal_colors] 157 | black = "#45475A" 158 | red = "#F38BA8" 159 | green = "#A6E3A1" 160 | yellow = "#F9E2AF" 161 | blue = "#89B4FA" 162 | magenta = "#F5C2E7" 163 | cyan = "#94E2D5" 164 | white = "#BAC2DE" 165 | 166 | [catppuccin-mocha.bright_colors] 167 | black = "#585B70" 168 | red = "#F38BA8" 169 | green = "#A6E3A1" 170 | yellow = "#F9E2AF" 171 | blue = "#89B4FA" 172 | magenta = "#F5C2E7" 173 | cyan = "#94E2D5" 174 | white = "#A6ADC8" 175 | 176 | [catppuccin-latte] 177 | [catppuccin-latte.default_colors] 178 | fg = "#4C4F69" 179 | bg = "#EFF1F5" 180 | 181 | [catppuccin-latte.normal_colors] 182 | black = "#5C5F77" 183 | red = "#D20F39" 184 | green = "#40A02B" 185 | yellow = "#DF8E1D" 186 | blue = "#1E66F5" 187 | magenta = "#EA76CB" 188 | cyan = "#179299" 189 | white = "#ACB0BE" 190 | 191 | [catppuccin-latte.bright_colors] 192 | black = "#6C6F85" 193 | red = "#D20F39" 194 | green = "#40A02B" 195 | yellow = "#DF8E1D" 196 | blue = "#1E66F5" 197 | magenta = "#EA76CB" 198 | cyan = "#179299" 199 | white = "#BCC0CC" 200 | 201 | [onedark] 202 | [onedark.default_colors] 203 | fg = "#979eab" 204 | bg = "#282c34" 205 | 206 | [onedark.normal_colors] 207 | black = "#282c34" 208 | red = "#e06c75" 209 | green = "#98c379" 210 | yellow = "#e5c07b" 211 | blue = "#61afef" 212 | magenta = "#be5046" 213 | cyan = "#56b6c2" 214 | white = "#979eab" 215 | 216 | [onedark.bright_colors] 217 | black = "#393e48" 218 | red = "#d19a66" 219 | green = "#56b6c2" 220 | yellow = "#e5c07b" 221 | blue = "#61afef" 222 | magenta = "#be5046" 223 | cyan = "#56b6c2" 224 | white = "#abb2bf" 225 | 226 | [monokai] 227 | [monokai.default_colors] 228 | fg = "#FCFCFA" 229 | bg = "#2D2A2E" 230 | 231 | [monokai.normal_colors] 232 | black = "#403E41" 233 | red = "#FF6188" 234 | green = "#A9DC76" 235 | yellow = "#FFD866" 236 | blue = "#FC9867" 237 | magenta = "#AB9DF2" 238 | cyan = "#78DCE8" 239 | white = "#FCFCFA" 240 | 241 | [monokai.bright_colors] 242 | black = "#727072" 243 | red = "#FF6188" 244 | green = "#A9DC76" 245 | yellow = "#FFD866" 246 | blue = "#FC9867" 247 | magenta = "#AB9DF2" 248 | cyan = "#78DCE8" 249 | white = "#FCFCFA" 250 | 251 | [everblush] 252 | [everblush.default_colors] 253 | fg = "#dadada" 254 | bg = "#141b1e" 255 | 256 | [everblush.normal_colors] 257 | black = "#232a2d" 258 | red = "#e57474" 259 | green = "#8ccf7e" 260 | yellow = "#e5c76b" 261 | blue = "#67b0e8" 262 | magenta = "#c47fd5" 263 | cyan = "#6cbfbf" 264 | white = "#b3b9b8" 265 | 266 | [everblush.bright_colors] 267 | black = "#2d3437" 268 | red = "#ef7e7e" 269 | green = "#96d988" 270 | yellow = "#f4d67a" 271 | blue = "#71baf2" 272 | magenta = "#ce89df" 273 | cyan = "#67cbe7" 274 | white = "#bdc3c2" 275 | 276 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

2 | github-readme-terminal 3 |
4 | ✨ Elevate your GitHub Profile ReadMe with Minimalistic Retro Terminal GIFs 🚀 5 |

6 | 7 | # 💻 GitHub ReadME Terminal 🎞️ 8 | 9 | [![Ruff](https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/astral-sh/ruff/main/assets/badge/v2.json)](https://github.com/astral-sh/ruff) 10 | [![Code style: black](https://img.shields.io/badge/code%20style-black-000000.svg)](https://github.com/psf/black) 11 | ![PyPI - Python Version](https://img.shields.io/pypi/pyversions/github-readme-terminal) 12 | ![PyPI - License](https://img.shields.io/pypi/l/github-readme-terminal) 13 | [![PyPI - Version](https://img.shields.io/pypi/v/github-readme-terminal)](https://pypi.org/project/github-readme-terminal/) 14 | 15 | ## 📘 Description 16 | 17 | A Python project that empowers you to create visually stunning and unique GIFs for your GitHub Profile ReadME. Unleash your creativity and make your profile stand out from the rest! 18 | 19 | ## 📸 Showcase 20 | 21 | 22 | 23 | 24 | GIFOS 25 | 26 | 27 | ## 🗝️ Key Features 28 | 29 | - 👾 **Retro Vibes** – Easily simulate a retro PC booting up into a *nix terminal and then running neofetch to display various details about your GitHub activity. 30 | - 🖼️ **Unleash Your Creativity** - Craft unique and eye-catching visuals with complete control. Your GitHub profile is your canvas, and github-readme-terminal is your paintbrush! 31 | - 📈 **Live GitHub Stats** - Keep your profile readme up to date with your latest achievements and contributions with built-in helper functions. 32 | - 🎨 **Choice of Color Schemes** – 10+ popular color schemes to choose from and full support for ANSI color escape sequences. 33 | - 🛠️ **TOML-based configuration** - Provides an easy and organized way to customize your terminal simulation. 34 | 35 | ## 🎯 Motivation 36 | 37 | - 🌈 **Customization** is at the heart of the project – no more settling for pre-defined templates. Tailor your GitHub Profile ReadME to reflect your personality. 38 | - 🌐 Unlike other GitHub user statistic generators, this project offers a **fresh approach** to showcasing your profile information. 39 | - 🚨 Stand out in the developer community with **visually appealing** GIFs that can potentially make a lasting impression. 40 | - 📦 **High-level constructs** and functions for simulating various terminal operations provide unparalleled control over your ReadME aesthetic. 41 | 42 | ## ⚙️ Prerequisites 43 | 44 | 1. Python >=3.9 45 | 2. [FFmpeg](https://ffmpeg.org/download.html) 46 | 3. [GitHub personal access token](https://docs.github.com/en/authentication/keeping-your-account-and-data-secure/managing-your-personal-access-tokens#creating-a-fine-grained-personal-access-token) (Optional) 47 | 4. [ImgBB API key](https://api.imgbb.com/) (Optional) 48 | 49 | ## 📦 Installation 50 | 51 | ⚙️ To install `github-readme-terminal`, you need `pip`: 52 | 53 | ```bash 54 | python -m pip install --upgrade github-readme-terminal 55 | ``` 56 | 57 | > [!NOTE] 58 | > The package includes only [gohufont-uni-14](https://github.com/hchargois/gohufont). Bring your own fonts, if you need additional ones. Also, refer to [Pillow documentation](https://pillow.readthedocs.io/en/stable/reference/ImageFont.html#module-PIL.ImageFont) if you need to work with bitmap fonts. 59 | 60 | ## 🪧 Usage 61 | 62 | Here is a basic demonstration: 63 | 64 | ```python 65 | import gifos 66 | 67 | t = gifos.Terminal(width=320, height=240, xpad=5, ypad=5) 68 | t.gen_text(text="Hello World!", row_num=1) 69 | t.gen_text(text="With \x1b[32mANSI\x1b[0m escape sequence support!", row_num=2) 70 | github_stats = gifos.utils.fetch_github_stats( 71 | user_name="x0rzavi" 72 | ) # needs GITHUB_TOKEN in .env or as environment variable 73 | t.delete_row(row_num=1) 74 | t.gen_text(text=f"GitHub Name: {github_stats.account_name}", row_num=1, contin=True) 75 | t.gen_gif() 76 | image = gifos.utils.upload_imgbb( 77 | file_name="output.gif", expiration=60 78 | ) # needs IMGBB_API_KEY in .env or as environment variable 79 | print(image.url) 80 | ``` 81 | 82 | For advanced usage, please refer [here](https://github.com/x0rzavi/x0rzavi) 83 | 84 | ## 🛠️ Configuration 85 | 86 | Tunable options can be set in two locations: 87 | 88 | 1. Inside TOML files located in `~/.config/gifos/`. 89 | 2. As environment variables. 90 | 91 | Environment variables override configuration in TOML files 92 | 93 | ### 📑 TOML configuration file format 94 | 95 | ```toml 96 | # gifos_settings.toml 97 | 98 | [general] 99 | debug = false 100 | cursor = "_" 101 | show_cursor = true 102 | blink_cursor = true 103 | user_name = "x0rzavi" # for prompt 104 | fps = 15 105 | color_scheme = "yoru" 106 | loop_count = 0 # infinite loop 107 | 108 | [files] 109 | frame_base_name = "frame_" 110 | frame_folder_name = "frames" 111 | output_gif_name = "output" 112 | ``` 113 | 114 | ```toml 115 | # ansi_escape_colors.toml 116 | 117 | [yoru] 118 | [yoru.default_colors] 119 | fg = "#edeff0" 120 | bg = "#0c0e0f" 121 | 122 | [yoru.normal_colors] 123 | black = "#232526" 124 | red = "#df5b61" 125 | green = "#78b892" 126 | yellow = "#de8f78" 127 | blue = "#6791c9" 128 | magenta = "#bc83e3" 129 | cyan = "#67afc1" 130 | white = "#e4e6e7" 131 | 132 | [yoru.bright_colors] 133 | black = "#2c2e2f" 134 | red = "#e8646a" 135 | green = "#81c19b" 136 | yellow = "#e79881" 137 | blue = "#709ad2" 138 | magenta = "#c58cec" 139 | cyan = "#70b8ca" 140 | white = "#f2f4f5" 141 | ``` 142 | 143 | ### 📑 Environment variables format 144 | 145 | ```bash 146 | export GIFOS_GENERAL_DEBUG=true 147 | export GIFOS_GENERAL_COLOR_SCHEME="catppuccin-mocha" 148 | export GIFOS_CATPPUCCIN-MOCHA_DEFAULT_COLORS_FG="white" 149 | export GIFOS_CATPPUCCIN-MOCHA_DEFAULT_COLORS_BG="black" 150 | # Other variables are named similarly 151 | ``` 152 | 153 | ### 📂 Optional API keys 154 | 155 | Optional API keys for modules must be present in `.env` file or declared as environment variables: 156 | 157 | 1. `GITHUB_TOKEN` 158 | - Repository access - All repositories 159 | - Repository permissions - Contents: Read-only 160 | 2. `IMGBB_API_KEY` 161 | 162 | ### 🌈 Color schemes included 163 | 164 | - [yoru](https://github.com/rxyhn/yoru#art--colorscheme) - Default 165 | - [gruvbox-dark](https://github.com/morhetz/gruvbox) 166 | - [gruvbox-light](https://github.com/morhetz/gruvbox) 167 | - [rose-pine](https://rosepinetheme.com/) 168 | - [dracula](https://draculatheme.com/) 169 | - [nord](https://www.nordtheme.com/) 170 | - [catppuccin-mocha](https://github.com/catppuccin/catppuccin) 171 | - [catppuccin-latte](https://github.com/catppuccin/catppuccin) 172 | - [onedark](https://github.com/navarasu/onedark.nvim) 173 | - [monokai](https://monokai.pro/) 174 | - [everblush](https://github.com/Everblush) 175 | 176 | ## 📃 Roadmap 177 | 178 | - [ ] Add proper documentation. 179 | - [ ] Add GitHub streak statistics. 180 | - [ ] Properly handle exceptions. 181 | - [ ] Add unit tests. 182 | - [ ] Support for more ANSI escape codes. 183 | - [ ] More in-built color schemes. 184 | - [ ] More in-built text animations. 185 | 186 | ## 🌱 Contributing 187 | 188 | This is an open source project licensed under MIT and we welcome contributions from the community. We appreciate all types of contributions, including bug reports, feature requests, documentation improvements, and code contributions. 189 | 190 | Read our [Contributing Guidelines](https://github.com/x0rzavi/github-readme-terminal/blob/main/CONTRIBUTING.md) to learn about our development process, how to propose bug fixes and improvements of our Project 191 | 192 |

Code Of Conduct📑

193 | 194 | This project and everyone participating in it is governed by the [Code of Conduct](https://github.com/x0rzavi/github-readme-terminal/blob/main/CODE_OF_CONDUCT.md). By participating, you are expected to uphold this code. 195 | 196 | ## 🤝 Acknowledgments 197 | 198 | - [liamg/liamg](https://github.com/liamg/liamg) - Inspiration. 199 | - [anuraghazra/github-readme-stats](https://github.com/anuraghazra/github-readme-stats) - GitHub Stats calculation logic. 200 | - [hchargois/gohufont](https://github.com/hchargois/gohufont) - Built-in font file. 201 | - Creators of all the color schemes included in this project. 202 | 203 | ## ✨ Craft your masterpiece with github-readme-terminal and showcase your unique GitHub profile [here](https://github.com/x0rzavi/github-readme-terminal/discussions/categories/show-and-tell) ✨ 204 | -------------------------------------------------------------------------------- /gifos/utils/fetch_github_stats.py: -------------------------------------------------------------------------------- 1 | # TODO 2 | # [] Language colors 3 | # [] Profile image ascii art 4 | # [] Optimize code 5 | # [] Optimize API calls 6 | # [] Catch errors 7 | # [] Retry on error 8 | 9 | import os 10 | import requests 11 | import sys 12 | 13 | from dotenv import load_dotenv 14 | 15 | from gifos.utils.calc_github_rank import calc_github_rank 16 | from gifos.utils.schemas.github_user_stats import GithubUserStats 17 | 18 | """This module contains a function for fetching a GitHub user's statistics.""" 19 | 20 | load_dotenv() 21 | GITHUB_TOKEN = os.getenv("GITHUB_TOKEN") 22 | GRAPHQL_ENDPOINT = "https://api.github.com/graphql" 23 | 24 | 25 | def fetch_repo_stats(user_name: str, repo_end_cursor: str = None) -> dict: 26 | """Fetch statistics for a user's repositories. 27 | 28 | This function sends a GraphQL query to the GitHub API to fetch statistics for a 29 | user's repositories. The function uses the `GITHUB_TOKEN` environment variable for 30 | authentication and the `GRAPHQL_ENDPOINT` constant for the API endpoint. 31 | 32 | :param user_name: The username of the user to fetch statistics for. 33 | :type user_name: str 34 | :param repo_end_cursor: The end cursor for pagination. If not provided, the function 35 | fetches statistics from the beginning. 36 | :type repo_end_cursor: str, optional 37 | :return: A dictionary containing the fetched statistics if the request is 38 | successful, otherwise None. 39 | :rtype: dict or None 40 | """ 41 | query = """ 42 | query repoInfo( 43 | $user_name: String! 44 | $repo_end_cursor: String 45 | ) { 46 | user(login: $user_name) { 47 | repositories ( 48 | first: 100, 49 | after: $repo_end_cursor 50 | orderBy: { field: STARGAZERS, direction: DESC } 51 | ownerAffiliations: OWNER 52 | ) { 53 | totalCount 54 | nodes { 55 | name 56 | isFork 57 | stargazerCount 58 | languages( 59 | first: 10 60 | orderBy: { field: SIZE, direction: DESC } 61 | ) { 62 | edges { 63 | node { 64 | name 65 | # color 66 | } 67 | size 68 | } 69 | } 70 | } 71 | pageInfo { 72 | endCursor 73 | hasNextPage 74 | } 75 | } 76 | } 77 | # rateLimit { 78 | # cost 79 | # limit 80 | # remaining 81 | # used 82 | # resetAt 83 | # } 84 | } 85 | """ 86 | headers = {"Authorization": f"bearer {GITHUB_TOKEN}"} 87 | variables = {"user_name": user_name, "repo_end_cursor": repo_end_cursor} 88 | 89 | response = requests.post( 90 | GRAPHQL_ENDPOINT, 91 | json={"query": query, "variables": variables}, 92 | headers=headers, 93 | ) 94 | 95 | if response.status_code == 200: 96 | json_obj = response.json() 97 | if "errors" in json_obj: 98 | print(f"ERROR: {json_obj['errors']}") 99 | return None 100 | else: 101 | print(f"INFO: Repository details fetched for {user_name}") 102 | return json_obj["data"]["user"]["repositories"] 103 | else: 104 | print(f"ERROR: {response.status_code}") 105 | return None 106 | 107 | 108 | def fetch_user_stats(user_name: str) -> dict: 109 | """Fetch statistics for a GitHub user. 110 | 111 | This function sends a GraphQL query to the GitHub API to fetch statistics for a 112 | GitHub user. The function uses the `GITHUB_TOKEN` environment variable for 113 | authentication and the `GRAPHQL_ENDPOINT` constant for the API endpoint. 114 | 115 | :param user_name: The username of the user to fetch statistics for. 116 | :type user_name: str 117 | :return: A dictionary containing the fetched statistics if the request is 118 | successful, otherwise None. 119 | :rtype: dict or None 120 | """ 121 | query = """ 122 | query userInfo($user_name: String!) { 123 | user(login: $user_name) { 124 | name 125 | followers (first: 1) { 126 | totalCount 127 | } 128 | repositoriesContributedTo ( 129 | first: 1 130 | contributionTypes: [COMMIT, ISSUE, PULL_REQUEST, REPOSITORY] 131 | ) { 132 | totalCount 133 | } 134 | contributionsCollection { 135 | # contributionCalendar { 136 | # totalContributions 137 | # } 138 | totalCommitContributions 139 | restrictedContributionsCount 140 | totalPullRequestReviewContributions 141 | } 142 | issues(first: 1) { 143 | totalCount 144 | } 145 | pullRequests(first: 1) { 146 | totalCount 147 | } 148 | mergedPullRequests: pullRequests(states: MERGED, first: 1) { 149 | totalCount 150 | } 151 | } 152 | # rateLimit { 153 | # cost 154 | # limit 155 | # remaining 156 | # used 157 | # resetAt 158 | # } 159 | } 160 | """ 161 | 162 | headers = {"Authorization": f"bearer {GITHUB_TOKEN}"} 163 | variables = {"user_name": user_name} 164 | 165 | response = requests.post( 166 | GRAPHQL_ENDPOINT, 167 | json={"query": query, "variables": variables}, 168 | headers=headers, 169 | ) 170 | 171 | if response.status_code == 200: 172 | json_obj = response.json() 173 | if "errors" in json_obj: 174 | print(f"ERROR: {json_obj['errors']}") 175 | return None 176 | else: 177 | print(f"INFO: User details fetched for {user_name}") 178 | return json_obj["data"]["user"] 179 | else: 180 | print(f"ERROR: {response.status_code}") 181 | return None 182 | 183 | 184 | # Reference: https://github.com/anuraghazra/github-readme-stats/blob/23472f40e81170ba452c38a99abc674db0000ce6/src/fetchers/stats-fetcher.js#L170 185 | def fetch_total_commits(user_name: str) -> int: 186 | """Fetch the total number of commits (lifetime) made by a GitHub user. 187 | 188 | This function sends a GET request to the GitHub REST API to fetch the total number 189 | of commits made by a GitHub user. The function uses the `GITHUB_TOKEN` environment 190 | variable for authentication. 191 | 192 | :param user_name: The username of the user to fetch the total number of commits for. 193 | :type user_name: str 194 | :return: The total number of commits made by the user if the request is successful, 195 | otherwise None. 196 | :rtype: int or None 197 | """ 198 | REST_API_URL = f"https://api.github.com/search/commits?q=author:{user_name}" 199 | headers = { 200 | "Content-Type": "application/json", 201 | "User-Agent": "x0rzavi", 202 | "Accept": "application/vnd.github+json", 203 | "Authorization": f"token {GITHUB_TOKEN}", 204 | } 205 | response = requests.get(REST_API_URL, headers=headers) 206 | if response.status_code == 200: 207 | json_obj = response.json() 208 | total_commits_all_time = json_obj["total_count"] 209 | print(f"INFO: Total commits fetched for {user_name}") 210 | return total_commits_all_time 211 | else: 212 | print(f"ERROR: {response.status_code}") 213 | return None 214 | 215 | 216 | def fetch_github_stats( 217 | user_name: str, ignore_repos: list = None, include_all_commits: bool = False 218 | ) -> GithubUserStats: 219 | """Fetch GitHub statistics for a user. 220 | 221 | This function fetches various statistics for a GitHub user. The function uses the 222 | `GITHUB_TOKEN` environment variable for authentication. 223 | 224 | :param user_name: The username of the user to fetch statistics for. 225 | :type user_name: str 226 | :param ignore_repos: A list of repository names to ignore when fetching statistics. 227 | If not provided, all repositories are included. 228 | :type ignore_repos: list, optional 229 | :param include_all_commits: A boolean indicating whether to include all commits when 230 | calculating the user's GitHub rank. If False, only commits from the last year 231 | are included. 232 | :type include_all_commits: bool, optional 233 | :return: A `GithubUserStats` object containing the fetched statistics if the request 234 | is successful, otherwise None. 235 | :rtype: GithubUserStats or None 236 | """ 237 | if not GITHUB_TOKEN: 238 | print("ERROR: Please provide GITHUB_TOKEN") 239 | sys.exit(1) 240 | 241 | repo_end_cursor = None 242 | total_stargazers = 0 243 | languages_dict = {} 244 | 245 | def update_languages(languages, languages_dict): 246 | for language in languages: 247 | language_name = language["node"]["name"] 248 | language_size = language["size"] 249 | languages_dict[language_name] = ( 250 | languages_dict.get(language_name, 0) + language_size 251 | ) 252 | 253 | def process_repo(repos, ignore_repos, languages_dict): 254 | total_stargazers = 0 255 | for repo in repos: 256 | if repo["name"] not in (ignore_repos or []): 257 | total_stargazers += repo["stargazerCount"] 258 | if not repo["isFork"]: 259 | update_languages(repo["languages"]["edges"], languages_dict) 260 | return total_stargazers 261 | 262 | while True: # paginate repository stats 263 | repo_stats = fetch_repo_stats(user_name, repo_end_cursor) 264 | if repo_stats: 265 | total_stargazers = process_repo( 266 | repo_stats["nodes"], ignore_repos, languages_dict 267 | ) 268 | if repo_stats["pageInfo"]["hasNextPage"]: 269 | repo_end_cursor = repo_stats["pageInfo"]["endCursor"] 270 | else: 271 | break 272 | else: 273 | break 274 | 275 | total_commits_all_time = fetch_total_commits(user_name) # fetch only once 276 | total_languages_size = sum(languages_dict.values()) 277 | languages_percentage = { 278 | language: round((size / total_languages_size) * 100, 2) 279 | for language, size in languages_dict.items() 280 | } 281 | languages_sorted = sorted( 282 | languages_percentage.items(), key=lambda n: n[1], reverse=True 283 | ) 284 | 285 | user_stats = fetch_user_stats(user_name) 286 | 287 | if user_stats: 288 | if user_stats["pullRequests"]["totalCount"] > 0: 289 | pull_requests_merge_percentage = round( 290 | ( 291 | user_stats["mergedPullRequests"]["totalCount"] 292 | / user_stats["pullRequests"]["totalCount"] 293 | ) 294 | * 100, 295 | 2, 296 | ) 297 | else: 298 | pull_requests_merge_percentage = 0 299 | 300 | user_details = GithubUserStats( 301 | account_name=user_stats["name"], 302 | total_followers=user_stats["followers"]["totalCount"], 303 | total_stargazers=total_stargazers, 304 | total_issues=user_stats["issues"]["totalCount"], 305 | total_commits_all_time=total_commits_all_time, 306 | total_commits_last_year=( 307 | user_stats["contributionsCollection"]["restrictedContributionsCount"] 308 | + user_stats["contributionsCollection"]["totalCommitContributions"] 309 | ), 310 | total_pull_requests_made=user_stats["pullRequests"]["totalCount"], 311 | total_pull_requests_merged=user_stats["mergedPullRequests"]["totalCount"], 312 | pull_requests_merge_percentage=pull_requests_merge_percentage, 313 | total_pull_requests_reviewed=user_stats["contributionsCollection"][ 314 | "totalPullRequestReviewContributions" 315 | ], 316 | total_repo_contributions=user_stats["repositoriesContributedTo"][ 317 | "totalCount" 318 | ], 319 | languages_sorted=languages_sorted[:6], # top 6 languages 320 | user_rank=calc_github_rank( 321 | include_all_commits, 322 | total_commits_all_time 323 | if include_all_commits 324 | else ( 325 | user_stats["contributionsCollection"][ 326 | "restrictedContributionsCount" 327 | ] 328 | + user_stats["contributionsCollection"]["totalCommitContributions"] 329 | ), 330 | user_stats["pullRequests"]["totalCount"], 331 | user_stats["issues"]["totalCount"], 332 | user_stats["contributionsCollection"][ 333 | "totalPullRequestReviewContributions" 334 | ], 335 | total_stargazers, 336 | user_stats["followers"]["totalCount"], 337 | ), 338 | ) 339 | return user_details 340 | else: 341 | return None 342 | -------------------------------------------------------------------------------- /poetry.lock: -------------------------------------------------------------------------------- 1 | # This file is automatically @generated by Poetry 1.8.3 and should not be changed by hand. 2 | 3 | [[package]] 4 | name = "asttokens" 5 | version = "2.4.1" 6 | description = "Annotate AST trees with source code positions" 7 | optional = false 8 | python-versions = "*" 9 | files = [ 10 | {file = "asttokens-2.4.1-py2.py3-none-any.whl", hash = "sha256:051ed49c3dcae8913ea7cd08e46a606dba30b79993209636c4875bc1d637bc24"}, 11 | {file = "asttokens-2.4.1.tar.gz", hash = "sha256:b03869718ba9a6eb027e134bfdf69f38a236d681c83c160d510768af11254ba0"}, 12 | ] 13 | 14 | [package.dependencies] 15 | six = ">=1.12.0" 16 | 17 | [package.extras] 18 | astroid = ["astroid (>=1,<2)", "astroid (>=2,<4)"] 19 | test = ["astroid (>=1,<2)", "astroid (>=2,<4)", "pytest"] 20 | 21 | [[package]] 22 | name = "certifi" 23 | version = "2024.8.30" 24 | description = "Python package for providing Mozilla's CA Bundle." 25 | optional = false 26 | python-versions = ">=3.6" 27 | files = [ 28 | {file = "certifi-2024.8.30-py3-none-any.whl", hash = "sha256:922820b53db7a7257ffbda3f597266d435245903d80737e34f8a45ff3e3230d8"}, 29 | {file = "certifi-2024.8.30.tar.gz", hash = "sha256:bec941d2aa8195e248a60b31ff9f0558284cf01a52591ceda73ea9afffd69fd9"}, 30 | ] 31 | 32 | [[package]] 33 | name = "charset-normalizer" 34 | version = "3.4.0" 35 | description = "The Real First Universal Charset Detector. Open, modern and actively maintained alternative to Chardet." 36 | optional = false 37 | python-versions = ">=3.7.0" 38 | files = [ 39 | {file = "charset_normalizer-3.4.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:4f9fc98dad6c2eaa32fc3af1417d95b5e3d08aff968df0cd320066def971f9a6"}, 40 | {file = "charset_normalizer-3.4.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:0de7b687289d3c1b3e8660d0741874abe7888100efe14bd0f9fd7141bcbda92b"}, 41 | {file = "charset_normalizer-3.4.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:5ed2e36c3e9b4f21dd9422f6893dec0abf2cca553af509b10cd630f878d3eb99"}, 42 | {file = "charset_normalizer-3.4.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:40d3ff7fc90b98c637bda91c89d51264a3dcf210cade3a2c6f838c7268d7a4ca"}, 43 | {file = "charset_normalizer-3.4.0-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1110e22af8ca26b90bd6364fe4c763329b0ebf1ee213ba32b68c73de5752323d"}, 44 | {file = "charset_normalizer-3.4.0-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:86f4e8cca779080f66ff4f191a685ced73d2f72d50216f7112185dc02b90b9b7"}, 45 | {file = "charset_normalizer-3.4.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7f683ddc7eedd742e2889d2bfb96d69573fde1d92fcb811979cdb7165bb9c7d3"}, 46 | {file = "charset_normalizer-3.4.0-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:27623ba66c183eca01bf9ff833875b459cad267aeeb044477fedac35e19ba907"}, 47 | {file = "charset_normalizer-3.4.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:f606a1881d2663630ea5b8ce2efe2111740df4b687bd78b34a8131baa007f79b"}, 48 | {file = "charset_normalizer-3.4.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:0b309d1747110feb25d7ed6b01afdec269c647d382c857ef4663bbe6ad95a912"}, 49 | {file = "charset_normalizer-3.4.0-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:136815f06a3ae311fae551c3df1f998a1ebd01ddd424aa5603a4336997629e95"}, 50 | {file = "charset_normalizer-3.4.0-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:14215b71a762336254351b00ec720a8e85cada43b987da5a042e4ce3e82bd68e"}, 51 | {file = "charset_normalizer-3.4.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:79983512b108e4a164b9c8d34de3992f76d48cadc9554c9e60b43f308988aabe"}, 52 | {file = "charset_normalizer-3.4.0-cp310-cp310-win32.whl", hash = "sha256:c94057af19bc953643a33581844649a7fdab902624d2eb739738a30e2b3e60fc"}, 53 | {file = "charset_normalizer-3.4.0-cp310-cp310-win_amd64.whl", hash = "sha256:55f56e2ebd4e3bc50442fbc0888c9d8c94e4e06a933804e2af3e89e2f9c1c749"}, 54 | {file = "charset_normalizer-3.4.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:0d99dd8ff461990f12d6e42c7347fd9ab2532fb70e9621ba520f9e8637161d7c"}, 55 | {file = "charset_normalizer-3.4.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:c57516e58fd17d03ebe67e181a4e4e2ccab1168f8c2976c6a334d4f819fe5944"}, 56 | {file = "charset_normalizer-3.4.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:6dba5d19c4dfab08e58d5b36304b3f92f3bd5d42c1a3fa37b5ba5cdf6dfcbcee"}, 57 | {file = "charset_normalizer-3.4.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bf4475b82be41b07cc5e5ff94810e6a01f276e37c2d55571e3fe175e467a1a1c"}, 58 | {file = "charset_normalizer-3.4.0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ce031db0408e487fd2775d745ce30a7cd2923667cf3b69d48d219f1d8f5ddeb6"}, 59 | {file = "charset_normalizer-3.4.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8ff4e7cdfdb1ab5698e675ca622e72d58a6fa2a8aa58195de0c0061288e6e3ea"}, 60 | {file = "charset_normalizer-3.4.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3710a9751938947e6327ea9f3ea6332a09bf0ba0c09cae9cb1f250bd1f1549bc"}, 61 | {file = "charset_normalizer-3.4.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:82357d85de703176b5587dbe6ade8ff67f9f69a41c0733cf2425378b49954de5"}, 62 | {file = "charset_normalizer-3.4.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:47334db71978b23ebcf3c0f9f5ee98b8d65992b65c9c4f2d34c2eaf5bcaf0594"}, 63 | {file = "charset_normalizer-3.4.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:8ce7fd6767a1cc5a92a639b391891bf1c268b03ec7e021c7d6d902285259685c"}, 64 | {file = "charset_normalizer-3.4.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:f1a2f519ae173b5b6a2c9d5fa3116ce16e48b3462c8b96dfdded11055e3d6365"}, 65 | {file = "charset_normalizer-3.4.0-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:63bc5c4ae26e4bc6be6469943b8253c0fd4e4186c43ad46e713ea61a0ba49129"}, 66 | {file = "charset_normalizer-3.4.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:bcb4f8ea87d03bc51ad04add8ceaf9b0f085ac045ab4d74e73bbc2dc033f0236"}, 67 | {file = "charset_normalizer-3.4.0-cp311-cp311-win32.whl", hash = "sha256:9ae4ef0b3f6b41bad6366fb0ea4fc1d7ed051528e113a60fa2a65a9abb5b1d99"}, 68 | {file = "charset_normalizer-3.4.0-cp311-cp311-win_amd64.whl", hash = "sha256:cee4373f4d3ad28f1ab6290684d8e2ebdb9e7a1b74fdc39e4c211995f77bec27"}, 69 | {file = "charset_normalizer-3.4.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:0713f3adb9d03d49d365b70b84775d0a0d18e4ab08d12bc46baa6132ba78aaf6"}, 70 | {file = "charset_normalizer-3.4.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:de7376c29d95d6719048c194a9cf1a1b0393fbe8488a22008610b0361d834ecf"}, 71 | {file = "charset_normalizer-3.4.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:4a51b48f42d9358460b78725283f04bddaf44a9358197b889657deba38f329db"}, 72 | {file = "charset_normalizer-3.4.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b295729485b06c1a0683af02a9e42d2caa9db04a373dc38a6a58cdd1e8abddf1"}, 73 | {file = "charset_normalizer-3.4.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ee803480535c44e7f5ad00788526da7d85525cfefaf8acf8ab9a310000be4b03"}, 74 | {file = "charset_normalizer-3.4.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3d59d125ffbd6d552765510e3f31ed75ebac2c7470c7274195b9161a32350284"}, 75 | {file = "charset_normalizer-3.4.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8cda06946eac330cbe6598f77bb54e690b4ca93f593dee1568ad22b04f347c15"}, 76 | {file = "charset_normalizer-3.4.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:07afec21bbbbf8a5cc3651aa96b980afe2526e7f048fdfb7f1014d84acc8b6d8"}, 77 | {file = "charset_normalizer-3.4.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:6b40e8d38afe634559e398cc32b1472f376a4099c75fe6299ae607e404c033b2"}, 78 | {file = "charset_normalizer-3.4.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:b8dcd239c743aa2f9c22ce674a145e0a25cb1566c495928440a181ca1ccf6719"}, 79 | {file = "charset_normalizer-3.4.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:84450ba661fb96e9fd67629b93d2941c871ca86fc38d835d19d4225ff946a631"}, 80 | {file = "charset_normalizer-3.4.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:44aeb140295a2f0659e113b31cfe92c9061622cadbc9e2a2f7b8ef6b1e29ef4b"}, 81 | {file = "charset_normalizer-3.4.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:1db4e7fefefd0f548d73e2e2e041f9df5c59e178b4c72fbac4cc6f535cfb1565"}, 82 | {file = "charset_normalizer-3.4.0-cp312-cp312-win32.whl", hash = "sha256:5726cf76c982532c1863fb64d8c6dd0e4c90b6ece9feb06c9f202417a31f7dd7"}, 83 | {file = "charset_normalizer-3.4.0-cp312-cp312-win_amd64.whl", hash = "sha256:b197e7094f232959f8f20541ead1d9862ac5ebea1d58e9849c1bf979255dfac9"}, 84 | {file = "charset_normalizer-3.4.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:dd4eda173a9fcccb5f2e2bd2a9f423d180194b1bf17cf59e3269899235b2a114"}, 85 | {file = "charset_normalizer-3.4.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e9e3c4c9e1ed40ea53acf11e2a386383c3304212c965773704e4603d589343ed"}, 86 | {file = "charset_normalizer-3.4.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:92a7e36b000bf022ef3dbb9c46bfe2d52c047d5e3f3343f43204263c5addc250"}, 87 | {file = "charset_normalizer-3.4.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:54b6a92d009cbe2fb11054ba694bc9e284dad30a26757b1e372a1fdddaf21920"}, 88 | {file = "charset_normalizer-3.4.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1ffd9493de4c922f2a38c2bf62b831dcec90ac673ed1ca182fe11b4d8e9f2a64"}, 89 | {file = "charset_normalizer-3.4.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:35c404d74c2926d0287fbd63ed5d27eb911eb9e4a3bb2c6d294f3cfd4a9e0c23"}, 90 | {file = "charset_normalizer-3.4.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4796efc4faf6b53a18e3d46343535caed491776a22af773f366534056c4e1fbc"}, 91 | {file = "charset_normalizer-3.4.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e7fdd52961feb4c96507aa649550ec2a0d527c086d284749b2f582f2d40a2e0d"}, 92 | {file = "charset_normalizer-3.4.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:92db3c28b5b2a273346bebb24857fda45601aef6ae1c011c0a997106581e8a88"}, 93 | {file = "charset_normalizer-3.4.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:ab973df98fc99ab39080bfb0eb3a925181454d7c3ac8a1e695fddfae696d9e90"}, 94 | {file = "charset_normalizer-3.4.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:4b67fdab07fdd3c10bb21edab3cbfe8cf5696f453afce75d815d9d7223fbe88b"}, 95 | {file = "charset_normalizer-3.4.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:aa41e526a5d4a9dfcfbab0716c7e8a1b215abd3f3df5a45cf18a12721d31cb5d"}, 96 | {file = "charset_normalizer-3.4.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:ffc519621dce0c767e96b9c53f09c5d215578e10b02c285809f76509a3931482"}, 97 | {file = "charset_normalizer-3.4.0-cp313-cp313-win32.whl", hash = "sha256:f19c1585933c82098c2a520f8ec1227f20e339e33aca8fa6f956f6691b784e67"}, 98 | {file = "charset_normalizer-3.4.0-cp313-cp313-win_amd64.whl", hash = "sha256:707b82d19e65c9bd28b81dde95249b07bf9f5b90ebe1ef17d9b57473f8a64b7b"}, 99 | {file = "charset_normalizer-3.4.0-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:dbe03226baf438ac4fda9e2d0715022fd579cb641c4cf639fa40d53b2fe6f3e2"}, 100 | {file = "charset_normalizer-3.4.0-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:dd9a8bd8900e65504a305bf8ae6fa9fbc66de94178c420791d0293702fce2df7"}, 101 | {file = "charset_normalizer-3.4.0-cp37-cp37m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b8831399554b92b72af5932cdbbd4ddc55c55f631bb13ff8fe4e6536a06c5c51"}, 102 | {file = "charset_normalizer-3.4.0-cp37-cp37m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a14969b8691f7998e74663b77b4c36c0337cb1df552da83d5c9004a93afdb574"}, 103 | {file = "charset_normalizer-3.4.0-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dcaf7c1524c0542ee2fc82cc8ec337f7a9f7edee2532421ab200d2b920fc97cf"}, 104 | {file = "charset_normalizer-3.4.0-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:425c5f215d0eecee9a56cdb703203dda90423247421bf0d67125add85d0c4455"}, 105 | {file = "charset_normalizer-3.4.0-cp37-cp37m-musllinux_1_2_aarch64.whl", hash = "sha256:d5b054862739d276e09928de37c79ddeec42a6e1bfc55863be96a36ba22926f6"}, 106 | {file = "charset_normalizer-3.4.0-cp37-cp37m-musllinux_1_2_i686.whl", hash = "sha256:f3e73a4255342d4eb26ef6df01e3962e73aa29baa3124a8e824c5d3364a65748"}, 107 | {file = "charset_normalizer-3.4.0-cp37-cp37m-musllinux_1_2_ppc64le.whl", hash = "sha256:2f6c34da58ea9c1a9515621f4d9ac379871a8f21168ba1b5e09d74250de5ad62"}, 108 | {file = "charset_normalizer-3.4.0-cp37-cp37m-musllinux_1_2_s390x.whl", hash = "sha256:f09cb5a7bbe1ecae6e87901a2eb23e0256bb524a79ccc53eb0b7629fbe7677c4"}, 109 | {file = "charset_normalizer-3.4.0-cp37-cp37m-musllinux_1_2_x86_64.whl", hash = "sha256:0099d79bdfcf5c1f0c2c72f91516702ebf8b0b8ddd8905f97a8aecf49712c621"}, 110 | {file = "charset_normalizer-3.4.0-cp37-cp37m-win32.whl", hash = "sha256:9c98230f5042f4945f957d006edccc2af1e03ed5e37ce7c373f00a5a4daa6149"}, 111 | {file = "charset_normalizer-3.4.0-cp37-cp37m-win_amd64.whl", hash = "sha256:62f60aebecfc7f4b82e3f639a7d1433a20ec32824db2199a11ad4f5e146ef5ee"}, 112 | {file = "charset_normalizer-3.4.0-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:af73657b7a68211996527dbfeffbb0864e043d270580c5aef06dc4b659a4b578"}, 113 | {file = "charset_normalizer-3.4.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:cab5d0b79d987c67f3b9e9c53f54a61360422a5a0bc075f43cab5621d530c3b6"}, 114 | {file = "charset_normalizer-3.4.0-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:9289fd5dddcf57bab41d044f1756550f9e7cf0c8e373b8cdf0ce8773dc4bd417"}, 115 | {file = "charset_normalizer-3.4.0-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6b493a043635eb376e50eedf7818f2f322eabbaa974e948bd8bdd29eb7ef2a51"}, 116 | {file = "charset_normalizer-3.4.0-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9fa2566ca27d67c86569e8c85297aaf413ffab85a8960500f12ea34ff98e4c41"}, 117 | {file = "charset_normalizer-3.4.0-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a8e538f46104c815be19c975572d74afb53f29650ea2025bbfaef359d2de2f7f"}, 118 | {file = "charset_normalizer-3.4.0-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6fd30dc99682dc2c603c2b315bded2799019cea829f8bf57dc6b61efde6611c8"}, 119 | {file = "charset_normalizer-3.4.0-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:2006769bd1640bdf4d5641c69a3d63b71b81445473cac5ded39740a226fa88ab"}, 120 | {file = "charset_normalizer-3.4.0-cp38-cp38-musllinux_1_2_aarch64.whl", hash = "sha256:dc15e99b2d8a656f8e666854404f1ba54765871104e50c8e9813af8a7db07f12"}, 121 | {file = "charset_normalizer-3.4.0-cp38-cp38-musllinux_1_2_i686.whl", hash = "sha256:ab2e5bef076f5a235c3774b4f4028a680432cded7cad37bba0fd90d64b187d19"}, 122 | {file = "charset_normalizer-3.4.0-cp38-cp38-musllinux_1_2_ppc64le.whl", hash = "sha256:4ec9dd88a5b71abfc74e9df5ebe7921c35cbb3b641181a531ca65cdb5e8e4dea"}, 123 | {file = "charset_normalizer-3.4.0-cp38-cp38-musllinux_1_2_s390x.whl", hash = "sha256:43193c5cda5d612f247172016c4bb71251c784d7a4d9314677186a838ad34858"}, 124 | {file = "charset_normalizer-3.4.0-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:aa693779a8b50cd97570e5a0f343538a8dbd3e496fa5dcb87e29406ad0299654"}, 125 | {file = "charset_normalizer-3.4.0-cp38-cp38-win32.whl", hash = "sha256:7706f5850360ac01d80c89bcef1640683cc12ed87f42579dab6c5d3ed6888613"}, 126 | {file = "charset_normalizer-3.4.0-cp38-cp38-win_amd64.whl", hash = "sha256:c3e446d253bd88f6377260d07c895816ebf33ffffd56c1c792b13bff9c3e1ade"}, 127 | {file = "charset_normalizer-3.4.0-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:980b4f289d1d90ca5efcf07958d3eb38ed9c0b7676bf2831a54d4f66f9c27dfa"}, 128 | {file = "charset_normalizer-3.4.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:f28f891ccd15c514a0981f3b9db9aa23d62fe1a99997512b0491d2ed323d229a"}, 129 | {file = "charset_normalizer-3.4.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:a8aacce6e2e1edcb6ac625fb0f8c3a9570ccc7bfba1f63419b3769ccf6a00ed0"}, 130 | {file = "charset_normalizer-3.4.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bd7af3717683bea4c87acd8c0d3d5b44d56120b26fd3f8a692bdd2d5260c620a"}, 131 | {file = "charset_normalizer-3.4.0-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5ff2ed8194587faf56555927b3aa10e6fb69d931e33953943bc4f837dfee2242"}, 132 | {file = "charset_normalizer-3.4.0-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e91f541a85298cf35433bf66f3fab2a4a2cff05c127eeca4af174f6d497f0d4b"}, 133 | {file = "charset_normalizer-3.4.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:309a7de0a0ff3040acaebb35ec45d18db4b28232f21998851cfa709eeff49d62"}, 134 | {file = "charset_normalizer-3.4.0-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:285e96d9d53422efc0d7a17c60e59f37fbf3dfa942073f666db4ac71e8d726d0"}, 135 | {file = "charset_normalizer-3.4.0-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:5d447056e2ca60382d460a604b6302d8db69476fd2015c81e7c35417cfabe4cd"}, 136 | {file = "charset_normalizer-3.4.0-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:20587d20f557fe189b7947d8e7ec5afa110ccf72a3128d61a2a387c3313f46be"}, 137 | {file = "charset_normalizer-3.4.0-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:130272c698667a982a5d0e626851ceff662565379baf0ff2cc58067b81d4f11d"}, 138 | {file = "charset_normalizer-3.4.0-cp39-cp39-musllinux_1_2_s390x.whl", hash = "sha256:ab22fbd9765e6954bc0bcff24c25ff71dcbfdb185fcdaca49e81bac68fe724d3"}, 139 | {file = "charset_normalizer-3.4.0-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:7782afc9b6b42200f7362858f9e73b1f8316afb276d316336c0ec3bd73312742"}, 140 | {file = "charset_normalizer-3.4.0-cp39-cp39-win32.whl", hash = "sha256:2de62e8801ddfff069cd5c504ce3bc9672b23266597d4e4f50eda28846c322f2"}, 141 | {file = "charset_normalizer-3.4.0-cp39-cp39-win_amd64.whl", hash = "sha256:95c3c157765b031331dd4db3c775e58deaee050a3042fcad72cbc4189d7c8dca"}, 142 | {file = "charset_normalizer-3.4.0-py3-none-any.whl", hash = "sha256:fe9f97feb71aa9896b81973a7bbada8c49501dc73e58a10fcef6663af95e5079"}, 143 | {file = "charset_normalizer-3.4.0.tar.gz", hash = "sha256:223217c3d4f82c3ac5e29032b3f1c2eb0fb591b72161f86d93f5719079dae93e"}, 144 | ] 145 | 146 | [[package]] 147 | name = "colorama" 148 | version = "0.4.6" 149 | description = "Cross-platform colored terminal text." 150 | optional = false 151 | python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7" 152 | files = [ 153 | {file = "colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6"}, 154 | {file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"}, 155 | ] 156 | 157 | [[package]] 158 | name = "docformatter" 159 | version = "1.7.5" 160 | description = "Formats docstrings to follow PEP 257" 161 | optional = false 162 | python-versions = ">=3.7,<4.0" 163 | files = [ 164 | {file = "docformatter-1.7.5-py3-none-any.whl", hash = "sha256:a24f5545ed1f30af00d106f5d85dc2fce4959295687c24c8f39f5263afaf9186"}, 165 | {file = "docformatter-1.7.5.tar.gz", hash = "sha256:ffed3da0daffa2e77f80ccba4f0e50bfa2755e1c10e130102571c890a61b246e"}, 166 | ] 167 | 168 | [package.dependencies] 169 | charset_normalizer = ">=3.0.0,<4.0.0" 170 | untokenize = ">=0.1.1,<0.2.0" 171 | 172 | [package.extras] 173 | tomli = ["tomli (>=2.0.0,<3.0.0)"] 174 | 175 | [[package]] 176 | name = "executing" 177 | version = "2.1.0" 178 | description = "Get the currently executing AST node of a frame, and other information" 179 | optional = false 180 | python-versions = ">=3.8" 181 | files = [ 182 | {file = "executing-2.1.0-py2.py3-none-any.whl", hash = "sha256:8d63781349375b5ebccc3142f4b30350c0cd9c79f921cde38be2be4637e98eaf"}, 183 | {file = "executing-2.1.0.tar.gz", hash = "sha256:8ea27ddd260da8150fa5a708269c4a10e76161e2496ec3e587da9e3c0fe4b9ab"}, 184 | ] 185 | 186 | [package.extras] 187 | tests = ["asttokens (>=2.1.0)", "coverage", "coverage-enable-subprocess", "ipython", "littleutils", "pytest", "rich"] 188 | 189 | [[package]] 190 | name = "icecream" 191 | version = "2.1.3" 192 | description = "Never use print() to debug again; inspect variables, expressions, and program execution with a single, simple function call." 193 | optional = false 194 | python-versions = "*" 195 | files = [ 196 | {file = "icecream-2.1.3-py2.py3-none-any.whl", hash = "sha256:757aec31ad4488b949bc4f499d18e6e5973c40cc4d4fc607229e78cfaec94c34"}, 197 | {file = "icecream-2.1.3.tar.gz", hash = "sha256:0aa4a7c3374ec36153a1d08f81e3080e83d8ac1eefd97d2f4fe9544e8f9b49de"}, 198 | ] 199 | 200 | [package.dependencies] 201 | asttokens = ">=2.0.1" 202 | colorama = ">=0.3.9" 203 | executing = ">=0.3.1" 204 | pygments = ">=2.2.0" 205 | 206 | [[package]] 207 | name = "idna" 208 | version = "3.10" 209 | description = "Internationalized Domain Names in Applications (IDNA)" 210 | optional = false 211 | python-versions = ">=3.6" 212 | files = [ 213 | {file = "idna-3.10-py3-none-any.whl", hash = "sha256:946d195a0d259cbba61165e88e65941f16e9b36ea6ddb97f00452bae8b1287d3"}, 214 | {file = "idna-3.10.tar.gz", hash = "sha256:12f65c9b470abda6dc35cf8e63cc574b1c52b11df2c86030af0ac09b01b13ea9"}, 215 | ] 216 | 217 | [package.extras] 218 | all = ["flake8 (>=7.1.1)", "mypy (>=1.11.2)", "pytest (>=8.3.2)", "ruff (>=0.6.2)"] 219 | 220 | [[package]] 221 | name = "pillow" 222 | version = "10.4.0" 223 | description = "Python Imaging Library (Fork)" 224 | optional = false 225 | python-versions = ">=3.8" 226 | files = [ 227 | {file = "pillow-10.4.0-cp310-cp310-macosx_10_10_x86_64.whl", hash = "sha256:4d9667937cfa347525b319ae34375c37b9ee6b525440f3ef48542fcf66f2731e"}, 228 | {file = "pillow-10.4.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:543f3dc61c18dafb755773efc89aae60d06b6596a63914107f75459cf984164d"}, 229 | {file = "pillow-10.4.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7928ecbf1ece13956b95d9cbcfc77137652b02763ba384d9ab508099a2eca856"}, 230 | {file = "pillow-10.4.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e4d49b85c4348ea0b31ea63bc75a9f3857869174e2bf17e7aba02945cd218e6f"}, 231 | {file = "pillow-10.4.0-cp310-cp310-manylinux_2_28_aarch64.whl", hash = "sha256:6c762a5b0997f5659a5ef2266abc1d8851ad7749ad9a6a5506eb23d314e4f46b"}, 232 | {file = "pillow-10.4.0-cp310-cp310-manylinux_2_28_x86_64.whl", hash = "sha256:a985e028fc183bf12a77a8bbf36318db4238a3ded7fa9df1b9a133f1cb79f8fc"}, 233 | {file = "pillow-10.4.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:812f7342b0eee081eaec84d91423d1b4650bb9828eb53d8511bcef8ce5aecf1e"}, 234 | {file = "pillow-10.4.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:ac1452d2fbe4978c2eec89fb5a23b8387aba707ac72810d9490118817d9c0b46"}, 235 | {file = "pillow-10.4.0-cp310-cp310-win32.whl", hash = "sha256:bcd5e41a859bf2e84fdc42f4edb7d9aba0a13d29a2abadccafad99de3feff984"}, 236 | {file = "pillow-10.4.0-cp310-cp310-win_amd64.whl", hash = "sha256:ecd85a8d3e79cd7158dec1c9e5808e821feea088e2f69a974db5edf84dc53141"}, 237 | {file = "pillow-10.4.0-cp310-cp310-win_arm64.whl", hash = "sha256:ff337c552345e95702c5fde3158acb0625111017d0e5f24bf3acdb9cc16b90d1"}, 238 | {file = "pillow-10.4.0-cp311-cp311-macosx_10_10_x86_64.whl", hash = "sha256:0a9ec697746f268507404647e531e92889890a087e03681a3606d9b920fbee3c"}, 239 | {file = "pillow-10.4.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:dfe91cb65544a1321e631e696759491ae04a2ea11d36715eca01ce07284738be"}, 240 | {file = "pillow-10.4.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5dc6761a6efc781e6a1544206f22c80c3af4c8cf461206d46a1e6006e4429ff3"}, 241 | {file = "pillow-10.4.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5e84b6cc6a4a3d76c153a6b19270b3526a5a8ed6b09501d3af891daa2a9de7d6"}, 242 | {file = "pillow-10.4.0-cp311-cp311-manylinux_2_28_aarch64.whl", hash = "sha256:bbc527b519bd3aa9d7f429d152fea69f9ad37c95f0b02aebddff592688998abe"}, 243 | {file = "pillow-10.4.0-cp311-cp311-manylinux_2_28_x86_64.whl", hash = "sha256:76a911dfe51a36041f2e756b00f96ed84677cdeb75d25c767f296c1c1eda1319"}, 244 | {file = "pillow-10.4.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:59291fb29317122398786c2d44427bbd1a6d7ff54017075b22be9d21aa59bd8d"}, 245 | {file = "pillow-10.4.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:416d3a5d0e8cfe4f27f574362435bc9bae57f679a7158e0096ad2beb427b8696"}, 246 | {file = "pillow-10.4.0-cp311-cp311-win32.whl", hash = "sha256:7086cc1d5eebb91ad24ded9f58bec6c688e9f0ed7eb3dbbf1e4800280a896496"}, 247 | {file = "pillow-10.4.0-cp311-cp311-win_amd64.whl", hash = "sha256:cbed61494057c0f83b83eb3a310f0bf774b09513307c434d4366ed64f4128a91"}, 248 | {file = "pillow-10.4.0-cp311-cp311-win_arm64.whl", hash = "sha256:f5f0c3e969c8f12dd2bb7e0b15d5c468b51e5017e01e2e867335c81903046a22"}, 249 | {file = "pillow-10.4.0-cp312-cp312-macosx_10_10_x86_64.whl", hash = "sha256:673655af3eadf4df6b5457033f086e90299fdd7a47983a13827acf7459c15d94"}, 250 | {file = "pillow-10.4.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:866b6942a92f56300012f5fbac71f2d610312ee65e22f1aa2609e491284e5597"}, 251 | {file = "pillow-10.4.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:29dbdc4207642ea6aad70fbde1a9338753d33fb23ed6956e706936706f52dd80"}, 252 | {file = "pillow-10.4.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bf2342ac639c4cf38799a44950bbc2dfcb685f052b9e262f446482afaf4bffca"}, 253 | {file = "pillow-10.4.0-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:f5b92f4d70791b4a67157321c4e8225d60b119c5cc9aee8ecf153aace4aad4ef"}, 254 | {file = "pillow-10.4.0-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:86dcb5a1eb778d8b25659d5e4341269e8590ad6b4e8b44d9f4b07f8d136c414a"}, 255 | {file = "pillow-10.4.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:780c072c2e11c9b2c7ca37f9a2ee8ba66f44367ac3e5c7832afcfe5104fd6d1b"}, 256 | {file = "pillow-10.4.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:37fb69d905be665f68f28a8bba3c6d3223c8efe1edf14cc4cfa06c241f8c81d9"}, 257 | {file = "pillow-10.4.0-cp312-cp312-win32.whl", hash = "sha256:7dfecdbad5c301d7b5bde160150b4db4c659cee2b69589705b6f8a0c509d9f42"}, 258 | {file = "pillow-10.4.0-cp312-cp312-win_amd64.whl", hash = "sha256:1d846aea995ad352d4bdcc847535bd56e0fd88d36829d2c90be880ef1ee4668a"}, 259 | {file = "pillow-10.4.0-cp312-cp312-win_arm64.whl", hash = "sha256:e553cad5179a66ba15bb18b353a19020e73a7921296a7979c4a2b7f6a5cd57f9"}, 260 | {file = "pillow-10.4.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8bc1a764ed8c957a2e9cacf97c8b2b053b70307cf2996aafd70e91a082e70df3"}, 261 | {file = "pillow-10.4.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:6209bb41dc692ddfee4942517c19ee81b86c864b626dbfca272ec0f7cff5d9fb"}, 262 | {file = "pillow-10.4.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bee197b30783295d2eb680b311af15a20a8b24024a19c3a26431ff83eb8d1f70"}, 263 | {file = "pillow-10.4.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1ef61f5dd14c300786318482456481463b9d6b91ebe5ef12f405afbba77ed0be"}, 264 | {file = "pillow-10.4.0-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:297e388da6e248c98bc4a02e018966af0c5f92dfacf5a5ca22fa01cb3179bca0"}, 265 | {file = "pillow-10.4.0-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:e4db64794ccdf6cb83a59d73405f63adbe2a1887012e308828596100a0b2f6cc"}, 266 | {file = "pillow-10.4.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:bd2880a07482090a3bcb01f4265f1936a903d70bc740bfcb1fd4e8a2ffe5cf5a"}, 267 | {file = "pillow-10.4.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:4b35b21b819ac1dbd1233317adeecd63495f6babf21b7b2512d244ff6c6ce309"}, 268 | {file = "pillow-10.4.0-cp313-cp313-win32.whl", hash = "sha256:551d3fd6e9dc15e4c1eb6fc4ba2b39c0c7933fa113b220057a34f4bb3268a060"}, 269 | {file = "pillow-10.4.0-cp313-cp313-win_amd64.whl", hash = "sha256:030abdbe43ee02e0de642aee345efa443740aa4d828bfe8e2eb11922ea6a21ea"}, 270 | {file = "pillow-10.4.0-cp313-cp313-win_arm64.whl", hash = "sha256:5b001114dd152cfd6b23befeb28d7aee43553e2402c9f159807bf55f33af8a8d"}, 271 | {file = "pillow-10.4.0-cp38-cp38-macosx_10_10_x86_64.whl", hash = "sha256:8d4d5063501b6dd4024b8ac2f04962d661222d120381272deea52e3fc52d3736"}, 272 | {file = "pillow-10.4.0-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:7c1ee6f42250df403c5f103cbd2768a28fe1a0ea1f0f03fe151c8741e1469c8b"}, 273 | {file = "pillow-10.4.0-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b15e02e9bb4c21e39876698abf233c8c579127986f8207200bc8a8f6bb27acf2"}, 274 | {file = "pillow-10.4.0-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7a8d4bade9952ea9a77d0c3e49cbd8b2890a399422258a77f357b9cc9be8d680"}, 275 | {file = "pillow-10.4.0-cp38-cp38-manylinux_2_28_aarch64.whl", hash = "sha256:43efea75eb06b95d1631cb784aa40156177bf9dd5b4b03ff38979e048258bc6b"}, 276 | {file = "pillow-10.4.0-cp38-cp38-manylinux_2_28_x86_64.whl", hash = "sha256:950be4d8ba92aca4b2bb0741285a46bfae3ca699ef913ec8416c1b78eadd64cd"}, 277 | {file = "pillow-10.4.0-cp38-cp38-musllinux_1_2_aarch64.whl", hash = "sha256:d7480af14364494365e89d6fddc510a13e5a2c3584cb19ef65415ca57252fb84"}, 278 | {file = "pillow-10.4.0-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:73664fe514b34c8f02452ffb73b7a92c6774e39a647087f83d67f010eb9a0cf0"}, 279 | {file = "pillow-10.4.0-cp38-cp38-win32.whl", hash = "sha256:e88d5e6ad0d026fba7bdab8c3f225a69f063f116462c49892b0149e21b6c0a0e"}, 280 | {file = "pillow-10.4.0-cp38-cp38-win_amd64.whl", hash = "sha256:5161eef006d335e46895297f642341111945e2c1c899eb406882a6c61a4357ab"}, 281 | {file = "pillow-10.4.0-cp39-cp39-macosx_10_10_x86_64.whl", hash = "sha256:0ae24a547e8b711ccaaf99c9ae3cd975470e1a30caa80a6aaee9a2f19c05701d"}, 282 | {file = "pillow-10.4.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:298478fe4f77a4408895605f3482b6cc6222c018b2ce565c2b6b9c354ac3229b"}, 283 | {file = "pillow-10.4.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:134ace6dc392116566980ee7436477d844520a26a4b1bd4053f6f47d096997fd"}, 284 | {file = "pillow-10.4.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:930044bb7679ab003b14023138b50181899da3f25de50e9dbee23b61b4de2126"}, 285 | {file = "pillow-10.4.0-cp39-cp39-manylinux_2_28_aarch64.whl", hash = "sha256:c76e5786951e72ed3686e122d14c5d7012f16c8303a674d18cdcd6d89557fc5b"}, 286 | {file = "pillow-10.4.0-cp39-cp39-manylinux_2_28_x86_64.whl", hash = "sha256:b2724fdb354a868ddf9a880cb84d102da914e99119211ef7ecbdc613b8c96b3c"}, 287 | {file = "pillow-10.4.0-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:dbc6ae66518ab3c5847659e9988c3b60dc94ffb48ef9168656e0019a93dbf8a1"}, 288 | {file = "pillow-10.4.0-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:06b2f7898047ae93fad74467ec3d28fe84f7831370e3c258afa533f81ef7f3df"}, 289 | {file = "pillow-10.4.0-cp39-cp39-win32.whl", hash = "sha256:7970285ab628a3779aecc35823296a7869f889b8329c16ad5a71e4901a3dc4ef"}, 290 | {file = "pillow-10.4.0-cp39-cp39-win_amd64.whl", hash = "sha256:961a7293b2457b405967af9c77dcaa43cc1a8cd50d23c532e62d48ab6cdd56f5"}, 291 | {file = "pillow-10.4.0-cp39-cp39-win_arm64.whl", hash = "sha256:32cda9e3d601a52baccb2856b8ea1fc213c90b340c542dcef77140dfa3278a9e"}, 292 | {file = "pillow-10.4.0-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:5b4815f2e65b30f5fbae9dfffa8636d992d49705723fe86a3661806e069352d4"}, 293 | {file = "pillow-10.4.0-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:8f0aef4ef59694b12cadee839e2ba6afeab89c0f39a3adc02ed51d109117b8da"}, 294 | {file = "pillow-10.4.0-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9f4727572e2918acaa9077c919cbbeb73bd2b3ebcfe033b72f858fc9fbef0026"}, 295 | {file = "pillow-10.4.0-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ff25afb18123cea58a591ea0244b92eb1e61a1fd497bf6d6384f09bc3262ec3e"}, 296 | {file = "pillow-10.4.0-pp310-pypy310_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:dc3e2db6ba09ffd7d02ae9141cfa0ae23393ee7687248d46a7507b75d610f4f5"}, 297 | {file = "pillow-10.4.0-pp310-pypy310_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:02a2be69f9c9b8c1e97cf2713e789d4e398c751ecfd9967c18d0ce304efbf885"}, 298 | {file = "pillow-10.4.0-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:0755ffd4a0c6f267cccbae2e9903d95477ca2f77c4fcf3a3a09570001856c8a5"}, 299 | {file = "pillow-10.4.0-pp39-pypy39_pp73-macosx_10_15_x86_64.whl", hash = "sha256:a02364621fe369e06200d4a16558e056fe2805d3468350df3aef21e00d26214b"}, 300 | {file = "pillow-10.4.0-pp39-pypy39_pp73-macosx_11_0_arm64.whl", hash = "sha256:1b5dea9831a90e9d0721ec417a80d4cbd7022093ac38a568db2dd78363b00908"}, 301 | {file = "pillow-10.4.0-pp39-pypy39_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9b885f89040bb8c4a1573566bbb2f44f5c505ef6e74cec7ab9068c900047f04b"}, 302 | {file = "pillow-10.4.0-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:87dd88ded2e6d74d31e1e0a99a726a6765cda32d00ba72dc37f0651f306daaa8"}, 303 | {file = "pillow-10.4.0-pp39-pypy39_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:2db98790afc70118bd0255c2eeb465e9767ecf1f3c25f9a1abb8ffc8cfd1fe0a"}, 304 | {file = "pillow-10.4.0-pp39-pypy39_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:f7baece4ce06bade126fb84b8af1c33439a76d8a6fd818970215e0560ca28c27"}, 305 | {file = "pillow-10.4.0-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:cfdd747216947628af7b259d274771d84db2268ca062dd5faf373639d00113a3"}, 306 | {file = "pillow-10.4.0.tar.gz", hash = "sha256:166c1cd4d24309b30d61f79f4a9114b7b2313d7450912277855ff5dfd7cd4a06"}, 307 | ] 308 | 309 | [package.extras] 310 | docs = ["furo", "olefile", "sphinx (>=7.3)", "sphinx-copybutton", "sphinx-inline-tabs", "sphinxext-opengraph"] 311 | fpx = ["olefile"] 312 | mic = ["olefile"] 313 | tests = ["check-manifest", "coverage", "defusedxml", "markdown2", "olefile", "packaging", "pyroma", "pytest", "pytest-cov", "pytest-timeout"] 314 | typing = ["typing-extensions"] 315 | xmp = ["defusedxml"] 316 | 317 | [[package]] 318 | name = "pygments" 319 | version = "2.18.0" 320 | description = "Pygments is a syntax highlighting package written in Python." 321 | optional = false 322 | python-versions = ">=3.8" 323 | files = [ 324 | {file = "pygments-2.18.0-py3-none-any.whl", hash = "sha256:b8e6aca0523f3ab76fee51799c488e38782ac06eafcf95e7ba832985c8e7b13a"}, 325 | {file = "pygments-2.18.0.tar.gz", hash = "sha256:786ff802f32e91311bff3889f6e9a86e81505fe99f2735bb6d60ae0c5004f199"}, 326 | ] 327 | 328 | [package.extras] 329 | windows-terminal = ["colorama (>=0.4.6)"] 330 | 331 | [[package]] 332 | name = "python-dateutil" 333 | version = "2.9.0.post0" 334 | description = "Extensions to the standard Python datetime module" 335 | optional = false 336 | python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,>=2.7" 337 | files = [ 338 | {file = "python-dateutil-2.9.0.post0.tar.gz", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3"}, 339 | {file = "python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427"}, 340 | ] 341 | 342 | [package.dependencies] 343 | six = ">=1.5" 344 | 345 | [[package]] 346 | name = "python-dotenv" 347 | version = "1.0.1" 348 | description = "Read key-value pairs from a .env file and set them as environment variables" 349 | optional = false 350 | python-versions = ">=3.8" 351 | files = [ 352 | {file = "python-dotenv-1.0.1.tar.gz", hash = "sha256:e324ee90a023d808f1959c46bcbc04446a10ced277783dc6ee09987c37ec10ca"}, 353 | {file = "python_dotenv-1.0.1-py3-none-any.whl", hash = "sha256:f7b63ef50f1b690dddf550d03497b66d609393b40b564ed0d674909a68ebf16a"}, 354 | ] 355 | 356 | [package.extras] 357 | cli = ["click (>=5.0)"] 358 | 359 | [[package]] 360 | name = "requests" 361 | version = "2.32.3" 362 | description = "Python HTTP for Humans." 363 | optional = false 364 | python-versions = ">=3.8" 365 | files = [ 366 | {file = "requests-2.32.3-py3-none-any.whl", hash = "sha256:70761cfe03c773ceb22aa2f671b4757976145175cdfca038c02654d061d6dcc6"}, 367 | {file = "requests-2.32.3.tar.gz", hash = "sha256:55365417734eb18255590a9ff9eb97e9e1da868d4ccd6402399eaf68af20a760"}, 368 | ] 369 | 370 | [package.dependencies] 371 | certifi = ">=2017.4.17" 372 | charset-normalizer = ">=2,<4" 373 | idna = ">=2.5,<4" 374 | urllib3 = ">=1.21.1,<3" 375 | 376 | [package.extras] 377 | socks = ["PySocks (>=1.5.6,!=1.5.7)"] 378 | use-chardet-on-py3 = ["chardet (>=3.0.2,<6)"] 379 | 380 | [[package]] 381 | name = "six" 382 | version = "1.16.0" 383 | description = "Python 2 and 3 compatibility utilities" 384 | optional = false 385 | python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*" 386 | files = [ 387 | {file = "six-1.16.0-py2.py3-none-any.whl", hash = "sha256:8abb2f1d86890a2dfb989f9a77cfcfd3e47c2a354b01111771326f8aa26e0254"}, 388 | {file = "six-1.16.0.tar.gz", hash = "sha256:1e61c37477a1626458e36f7b1d82aa5c9b094fa4802892072e49de9c60c4c926"}, 389 | ] 390 | 391 | [[package]] 392 | name = "tomli" 393 | version = "2.0.2" 394 | description = "A lil' TOML parser" 395 | optional = false 396 | python-versions = ">=3.8" 397 | files = [ 398 | {file = "tomli-2.0.2-py3-none-any.whl", hash = "sha256:2ebe24485c53d303f690b0ec092806a085f07af5a5aa1464f3931eec36caaa38"}, 399 | {file = "tomli-2.0.2.tar.gz", hash = "sha256:d46d457a85337051c36524bc5349dd91b1877838e2979ac5ced3e710ed8a60ed"}, 400 | ] 401 | 402 | [[package]] 403 | name = "untokenize" 404 | version = "0.1.1" 405 | description = "Transforms tokens into original source code (while preserving whitespace)." 406 | optional = false 407 | python-versions = "*" 408 | files = [ 409 | {file = "untokenize-0.1.1.tar.gz", hash = "sha256:3865dbbbb8efb4bb5eaa72f1be7f3e0be00ea8b7f125c69cbd1f5fda926f37a2"}, 410 | ] 411 | 412 | [[package]] 413 | name = "urllib3" 414 | version = "2.2.3" 415 | description = "HTTP library with thread-safe connection pooling, file post, and more." 416 | optional = false 417 | python-versions = ">=3.8" 418 | files = [ 419 | {file = "urllib3-2.2.3-py3-none-any.whl", hash = "sha256:ca899ca043dcb1bafa3e262d73aa25c465bfb49e0bd9dd5d59f1d0acba2f8fac"}, 420 | {file = "urllib3-2.2.3.tar.gz", hash = "sha256:e7d814a81dad81e6caf2ec9fdedb284ecc9c73076b62654547cc64ccdcae26e9"}, 421 | ] 422 | 423 | [package.extras] 424 | brotli = ["brotli (>=1.0.9)", "brotlicffi (>=0.8.0)"] 425 | h2 = ["h2 (>=4,<5)"] 426 | socks = ["pysocks (>=1.5.6,!=1.5.7,<2.0)"] 427 | zstd = ["zstandard (>=0.18.0)"] 428 | 429 | [metadata] 430 | lock-version = "2.0" 431 | python-versions = "^3.9" 432 | content-hash = "83bfa35a5c19b210bb83d43b781fa1f2c2c07b04f5db47fd6bfc9250f1ead05f" 433 | -------------------------------------------------------------------------------- /gifos/gifos.py: -------------------------------------------------------------------------------- 1 | # TODO: 2 | # [] Documentation 3 | # [] proper file paths 4 | # [] incremental text effect 5 | # [] Better implementations for non monospace fonts 6 | # [] Support all ANSI escape sequence forms 7 | # [] Optimization + better code quality 8 | # [] Test cases 9 | # [] GIF maker implementation 10 | # [] Scriptable input file 11 | 12 | import os 13 | from math import ceil 14 | from pathlib import Path 15 | import random 16 | import re 17 | from shutil import rmtree 18 | import sys 19 | 20 | from icecream import ic 21 | from PIL import Image, ImageDraw, ImageFont 22 | 23 | from gifos.utils.convert_ansi_escape import ConvertAnsiEscape 24 | from gifos.utils.load_config import gifos_settings 25 | 26 | frame_base_name = gifos_settings.get("files", {}).get("frame_base_name") or "frame_" 27 | frame_folder_name = ( 28 | gifos_settings.get("files", {}).get("frame_folder_name") or "./frames" 29 | ) 30 | output_gif_name = gifos_settings.get("files", {}).get("output_gif_name") or "output" 31 | 32 | try: 33 | os.remove(output_gif_name + ".gif") 34 | except Exception: 35 | pass 36 | 37 | rmtree(frame_folder_name, ignore_errors=True) 38 | os.mkdir(frame_folder_name) 39 | 40 | font_path = Path(__file__).parent / "fonts" 41 | 42 | 43 | class Terminal: 44 | """A class to represent a terminal. 45 | 46 | This class represents a terminal with a specified width, height, padding, and font. 47 | 48 | Attributes: 49 | width: The width of the terminal. 50 | height: The height of the terminal. 51 | xpad: The horizontal padding of the terminal. 52 | ypad: The vertical padding of the terminal. 53 | font_file: The file path of the font to use for the terminal. Defaults to "gohufont-uni-14.pil". 54 | font_size: The size of the font to use for the terminal. Defaults to 16. 55 | line_spacing: The line spacing to use for the terminal. Defaults to 4. 56 | curr_row: The current row of the cursor in terminal. 57 | curr_col: The current column of the cursor in terminal. 58 | num_rows: The number of rows in the terminal. 59 | num_cols: The number of columns in the terminal. 60 | image_col: The column number of the last image pasted in the terminal. 61 | 62 | Methods: 63 | set_txt_color: Set the text color to be used. 64 | set_bg_color: Set the background color to be used. 65 | set_font: Set the font to be used. 66 | toggle_show_cursor: Toggle the visibility of the cursor. 67 | toggle_blink_cursor: Toggle the blinking of the cursor. 68 | save_frame: Save the current frame of the terminal. 69 | clear_frame: Clear the current frame of the terminal. 70 | clone_frame: Clone the current frame of the terminal. 71 | cursor_to_box: Move the cursor to a specified box (coordinate) in the terminal. 72 | gen_text: Generate text on the terminal. 73 | gen_typing_text: Generate text on the terminal as if it is being typed. 74 | set_prompt: Set the prompt text to be used. 75 | gen_prompt: Generate the prompt text on the terminal. 76 | scroll_up: Scroll up the terminal. 77 | delete_row: Delete a row in the terminal. 78 | paste_image: Paste an image on the terminal. 79 | set_fps: Set the FPS of the GIF to be generated. 80 | gen_gif: Generate the GIF from the frames. 81 | """ 82 | 83 | def __init__( 84 | self, 85 | width: int, 86 | height: int, 87 | xpad: int, 88 | ypad: int, 89 | font_file: str = f"{font_path}/gohufont-uni-14.pil", 90 | font_size: int = 16, 91 | line_spacing: int = 4, 92 | ) -> None: 93 | """Initialize a Terminal object. 94 | 95 | :param width: The width of the terminal. 96 | :type width: int 97 | :param height: The height of the terminal. 98 | :type height: int 99 | :param xpad: The horizontal padding of the terminal. 100 | :type xpad: int 101 | :param ypad: The vertical padding of the terminal. 102 | :type ypad: int 103 | :param font_file: The file path of the font to use for the terminal. 104 | :type font_file: str, optional 105 | :param font_size: The size of the font to use for the terminal. Defaults to 16. 106 | :type font_size: int, optional 107 | :param line_spacing: The line spacing to use for the terminal. Defaults to 4. 108 | :type line_spacing: int, optional 109 | """ 110 | ic.configureOutput(includeContext=True) 111 | self.__width = width 112 | self.__height = height 113 | self.__xpad = xpad 114 | self.__ypad = ypad 115 | self.__font_file = font_file 116 | self.__font_size = font_size 117 | self.__debug = gifos_settings.get("general", {}).get("debug") or False 118 | if not self.__debug: 119 | ic.disable() 120 | 121 | self.__txt_color = self.__def_txt_color = ConvertAnsiEscape.convert("39").data 122 | self.__bg_color = self.__def_bg_color = ConvertAnsiEscape.convert("49").data 123 | self.__frame_count = 0 124 | self.curr_row = 0 125 | self.curr_col = 0 126 | self.set_font(self.__font_file, self.__font_size, line_spacing) 127 | self.__cursor = gifos_settings.get("general", {}).get("cursor") or "_" 128 | self.__cursor_orig = self.__cursor 129 | self.__show_cursor = gifos_settings.get("general", {}).get("show_cursor", True) 130 | self.__blink_cursor = gifos_settings.get("general", {}).get( 131 | "blink_cursor", True 132 | ) 133 | self.__fps = gifos_settings.get("general", {}).get("fps") or 20 134 | self.__loop_count = gifos_settings.get("general", {}).get("loop_count") or 0 135 | self.__user_name = ( 136 | gifos_settings.get("general", {}).get("user_name") or "x0rzavi" 137 | ) 138 | self.__prompt = ( 139 | f"\x1b[0;91m{self.__user_name}\x1b[0m@\x1b[0;93mgifos ~> \x1b[0m" 140 | ) 141 | self.__frame = self.__gen_frame() 142 | 143 | def set_txt_color( 144 | self, 145 | txt_color: str = ConvertAnsiEscape.convert("39").data, 146 | ) -> None: 147 | """Set the text color to be used in the Terminal object. 148 | 149 | This method sets the text color of the Terminal object. 150 | 151 | :param txt_color: The text color to set. 152 | :type txt_color: str, optional 153 | """ 154 | self.__txt_color = txt_color 155 | 156 | def set_bg_color( 157 | self, 158 | bg_color: str = ConvertAnsiEscape.convert("49").data, 159 | ) -> None: 160 | """Set the background color to be used in the Terminal object. 161 | 162 | This method sets the background color of the Terminal object. 163 | 164 | :param bg_color: The text color to set. 165 | :type bg_color: str, optional 166 | """ 167 | self.__bg_color = bg_color 168 | 169 | def __check_font_type( 170 | self, font_file: str, font_size: int 171 | ) -> ImageFont.ImageFont | ImageFont.FreeTypeFont | None: 172 | """Check the type of the font file and return the appropriate font object. 173 | 174 | This method checks the type of the font file specified by `font_file`. If the font file is a TrueType font, it returns an `ImageFont.truetype` object with the specified `font_size`. If the font file is a bitmap font, it returns an `ImageFont.load` object and ignores the `font_size`. If the font file is neither a TrueType font nor a bitmap font, it returns `None`. 175 | 176 | :param font_file: The file path of the font file. 177 | :type font_file: str 178 | :param font_size: The size of the font. 179 | :type font_size: int 180 | :return: An `ImageFont.truetype` object if the font file is a TrueType font, an `ImageFont.load` object if the font file is a bitmap font, or `None` if the font file is neither a TrueType font nor a bitmap font. 181 | :rtype: ImageFont.ImageFont | ImageFont.FreeTypeFont | None 182 | """ 183 | try: 184 | font = ImageFont.truetype(font_file, font_size) 185 | return font 186 | except OSError: 187 | pass 188 | 189 | try: 190 | font = ImageFont.load(font_file) 191 | print(f"WARNING: {font_file} is BitMap - Ignoring size {font_size}") 192 | return font 193 | except OSError: 194 | return None 195 | 196 | def __check_monospace_font( 197 | self, font: ImageFont.ImageFont | ImageFont.FreeTypeFont 198 | ) -> dict: 199 | """Check if the specified font is monospaced and return a dictionary with the 200 | check result and the average width of the characters. 201 | 202 | This method checks if the specified font is monospaced by comparing the widths of the uppercase letters A-Z. If all the letters have the same width, the font is considered monospaced. The method returns a dictionary with the check result and the average width of the characters. 203 | 204 | :param font: The font to check. 205 | :type font: ImageFont.ImageFont | ImageFont.FreeTypeFont 206 | :return: A dictionary with the check result and the average width of the characters. The dictionary has the following keys: 207 | - "check": A boolean that is `True` if the font is monospaced and `False` otherwise. 208 | - "avg_width": The average width of the characters in the font. 209 | :rtype: dict 210 | """ 211 | widths = [font.getbbox(chr(i))[2] for i in range(ord("A"), ord("Z") + 1)] 212 | avg_width = int(round(sum(widths) / len(widths), 0)) 213 | return {"check": max(widths) == min(widths), "avg_width": avg_width} 214 | 215 | def set_font( 216 | self, font_file: str, font_size: int = 16, line_spacing: int = 4 217 | ) -> None: 218 | """Set the font to be used for the Terminal object. 219 | 220 | This method sets the font of the Terminal object. The font is specified by a 221 | file path and a size. The method also sets the line spacing of the terminal. If 222 | the font is monospaced, the method sets the width and height of the font to the 223 | width and height of the widest and tallest characters, respectively. If the font 224 | is not monospaced, the method sets the width of the font to the average width of 225 | the characters and the height of the font to the sum of the ascent and descent 226 | of the font. The method also calculates the number of rows and columns that can 227 | fit in the terminal based on the font size and line spacing. 228 | 229 | :param font_file: The file path of the font file. 230 | :type font_file: str 231 | :param font_size: The size of the font. Defaults to 16. 232 | :type font_size: int, optional 233 | :param line_spacing: The line spacing to use for the terminal. Defaults to 4. 234 | :type line_spacing: int, optional 235 | """ 236 | self.__font = self.__check_font_type(font_file, font_size) 237 | if self.__font: 238 | self.__line_spacing = line_spacing 239 | if self.__check_monospace_font(self.__font)["check"]: 240 | self.__font_width = self.__font.getbbox("W")[ 241 | 2 242 | ] # empirically widest character 243 | self.__font_height = self.__font.getbbox(r'|(/QMW"')[ 244 | 3 245 | ] # empirically tallest characters 246 | else: 247 | self.__font_width = self.__check_monospace_font(self.__font)[ 248 | "avg_width" 249 | ] # FIXME: rework 250 | font_metrics = self.__font.getmetrics() 251 | self.__font_height = font_metrics[0] + font_metrics[1] 252 | self.num_rows = (self.__height - 2 * self.__ypad) // ( 253 | self.__font_height + self.__line_spacing 254 | ) 255 | self.num_cols = (self.__width - 2 * self.__xpad) // (self.__font_width) 256 | print(f"INFO: Loaded font_file: {font_file}") 257 | print(f"INFO: Number of rows: {self.num_rows}") 258 | print(f"INFO: Number of columns: {self.num_cols}") 259 | self.__col_in_row = {_ + 1: 1 for _ in range(self.num_rows)} 260 | # self.clear_frame() 261 | ic(self.__font) # debug 262 | else: 263 | print(f"ERROR: Could not locate font_file {font_file}") 264 | sys.exit(1) 265 | 266 | def toggle_show_cursor(self, choice: bool = None) -> None: 267 | """Toggle the visibility of the cursor in the Terminal object. 268 | 269 | This method toggles the visibility of the cursor in the Terminal object. If `choice` is `None`, the method toggles the current visibility of the cursor. If `choice` is `True`, the method makes the cursor visible. If `choice` is `False`, the method makes the cursor invisible. 270 | 271 | :param choice: The desired visibility of the cursor. If `None`, the method toggles the current visibility of the cursor. If `True`, the method makes the cursor visible. If `False`, the method makes the cursor invisible. Defaults to `None`. 272 | :type choice: bool, optional 273 | """ 274 | self.__show_cursor = not self.__show_cursor if choice is None else choice 275 | ic(self.__show_cursor) # debug 276 | 277 | def toggle_blink_cursor(self, choice: bool = None) -> None: 278 | """Toggle the blinking of the cursor in the Terminal object. 279 | 280 | This method toggles the blinking of the cursor in the Terminal object. If `choice` is `None`, the method toggles the current blinking state of the cursor. If `choice` is `True`, the method makes the cursor blink. If `choice` is `False`, the method makes the cursor stop blinking. 281 | 282 | :param choice: The desired blinking state of the cursor. If `None`, the method toggles the current blinking state of the cursor. If `True`, the method makes the cursor blink. If `False`, the method makes the cursor stop blinking. Defaults to `None`. 283 | :type choice: bool, optional 284 | """ 285 | self.__blink_cursor = not self.__blink_cursor if choice is None else choice 286 | ic(self.__blink_cursor) # debug 287 | 288 | def __alter_cursor(self) -> None: 289 | """Alter the cursor of the Terminal object. 290 | 291 | This method alters the cursor of the Terminal object. If the current cursor is 292 | not the original cursor, the method sets the cursor to the original cursor; 293 | otherwise, it sets the cursor to a space. 294 | """ 295 | self.__cursor = ( 296 | self.__cursor_orig if self.__cursor != self.__cursor_orig else " " 297 | ) 298 | ic(self.__cursor) # debug 299 | 300 | def __check_multiline( 301 | self, text: str | list 302 | ) -> bool: # FIXME: make local to gen_text() ? 303 | """Check if the input text is multiline. 304 | 305 | This method checks if the input text is multiline. If the text is a list and has more than one element, the method returns `True`. If the text is a string and contains a newline character, the method returns `True`. In all other cases, the method returns `False`. 306 | 307 | :param text: The text to check. Can be a string or a list of strings. 308 | :type text: str | list 309 | :return: `True` if the text is multiline, `False` otherwise. 310 | :rtype: bool 311 | """ 312 | if isinstance(text, list): 313 | if len(text) <= 1: 314 | return False 315 | elif isinstance(text, str): 316 | return "\n" in text 317 | return True 318 | 319 | def __frame_debug_lines(self, frame: Image.Image) -> Image.Image: 320 | """Add debug lines to a frame. 321 | 322 | This method adds debug lines to a frame. The frame is specified by `frame`. The method draws horizontal and vertical lines on the frame to represent rows and columns, respectively. It also draws a red border around the frame. The row and column numbers are printed next to the corresponding lines. The method returns the frame with the added debug lines. 323 | 324 | :param frame: The frame to add debug lines to. 325 | :type frame: Image.Image 326 | :return: The frame with the added debug lines. 327 | :rtype: Image.Image 328 | """ 329 | # checker box to debug 330 | draw = ImageDraw.Draw(frame) 331 | for i in range(self.num_rows + 1): # (n + 1) lines 332 | x1 = self.__xpad 333 | x2 = self.__width - self.__xpad 334 | y1 = y2 = self.__ypad + i * (self.__font_height + self.__line_spacing) 335 | draw.line([(x1, y1), (x2, y2)], "yellow") 336 | draw.text((0, y1), str(i + 1), "orange", self.__font) # row numbers 337 | for i in range(self.num_cols + 1): # (n + 1) lines 338 | x1 = x2 = self.__xpad + i * self.__font_width 339 | y1 = self.__ypad 340 | y2 = self.__height - self.__ypad 341 | draw.line([(x1, y1), (x2, y2)], "turquoise") 342 | draw.line( 343 | [(self.__xpad, self.__ypad), (self.__width - self.__xpad, self.__ypad)], 344 | "red", 345 | ) # top 346 | draw.line( 347 | [(self.__xpad, self.__ypad), (self.__xpad, self.__height - self.__ypad)], 348 | "red", 349 | ) # left 350 | draw.line( 351 | [ 352 | (self.__xpad, self.__height - self.__ypad), 353 | (self.__width - self.__xpad, self.__height - self.__ypad), 354 | ], 355 | "red", 356 | ) # bottom 357 | draw.line( 358 | [ 359 | (self.__width - self.__xpad, self.__ypad), 360 | (self.__width - self.__xpad, self.__height - self.__ypad), 361 | ], 362 | "red", 363 | ) # right 364 | return frame 365 | 366 | def __gen_frame(self, frame: Image.Image = None) -> Image.Image: 367 | """Generate a new frame or save the current frame. 368 | 369 | This method generates a new frame if `frame` is `None` or saves the current frame if `frame` is not `None`. If a new frame is generated, the method initializes the frame with the background color, sets the number of columns in each row to 1, and moves the cursor to the box at (1, 1). If the debug mode is on, the method also draws debug lines on the frame. If the current frame is saved, the method increments the frame count, saves the frame as a PNG file with a name based on the frame count. 370 | 371 | :param frame: The current frame. If `None`, a new frame is generated. If not `None`, the current frame is saved. Defaults to `None`. 372 | :type frame: Image.Image, optional 373 | :return: The new or saved frame. 374 | :rtype: Image.Image 375 | """ 376 | if frame is None: 377 | frame = Image.new("RGB", (self.__width, self.__height), self.__bg_color) 378 | self.__col_in_row = {_ + 1: 1 for _ in range(self.num_rows)} 379 | if self.__debug: 380 | frame = self.__frame_debug_lines(frame) 381 | self.cursor_to_box(1, 1) # initialize at box (1, 1) 382 | return frame 383 | self.__frame_count += 1 384 | file_name = frame_base_name + str(self.__frame_count) + ".png" 385 | frame.save(frame_folder_name + "/" + file_name, "PNG") 386 | print(f"INFO: Generated frame #{self.__frame_count}") # debug 387 | return frame 388 | 389 | def save_frame(self, base_file_name: str): 390 | """Save the current frame as a PNG file. 391 | 392 | This method saves the current frame as a PNG file. The file name is based on `base_file_name`. 393 | 394 | :param base_file_name: The base file name for the PNG file. 395 | :type base_file_name: str 396 | """ 397 | file_name = base_file_name + ("" if ".png" in base_file_name else ".png") 398 | self.__frame.save(file_name, "PNG") 399 | print(f"INFO: Saved frame #{self.__frame_count}: {file_name}") 400 | 401 | def clear_frame(self) -> None: 402 | """Clear the current frame. 403 | 404 | This method clears the current frame by generating a new frame. The new frame is 405 | initialized with the background color, the number of columns in each row is set 406 | to 1, and the cursor is moved to the box at (1, 1). 407 | """ 408 | self.__frame = self.__gen_frame() 409 | ic("Frame cleared") 410 | 411 | def clone_frame(self, count: int = 1) -> None: 412 | """Clone the current frame a specified number of times. 413 | 414 | This method clones the current frame a specified number of times. The number of times to clone the frame is specified by `count`. 415 | 416 | :param count: The number of times to clone the frame. Defaults to 1. 417 | :type count: int, optional 418 | """ 419 | for _ in range(count): 420 | self.__frame = self.__gen_frame(self.__frame) 421 | ic(f"Frame cloned {count} times") 422 | 423 | def cursor_to_box( 424 | self, 425 | row_num: int, 426 | col_num: int, 427 | text_num_lines: int = 1, 428 | text_num_chars: int = 1, 429 | contin: bool = False, 430 | force_col: bool = False, # to assist in delete_row() 431 | ) -> tuple: 432 | """Move the cursor to a specific box (coordinate) in the Terminal object. 433 | 434 | This method moves the cursor to a specific box in the Terminal object. The box is specified by `row_num` and `col_num`. The method also takes into account the number of lines and characters in the text that will be printed at the box. If `contin` is `True`, the method continues printing from the current position of the cursor. If `force_col` is `True`, the method forces the cursor to move to the specified column. 435 | 436 | :param row_num: The row number of the box. 437 | :type row_num: int 438 | :param col_num: The column number of the box. 439 | :type col_num: int 440 | :param text_num_lines: The number of lines in the text that will be printed at the box. Defaults to 1. 441 | :type text_num_lines: int, optional 442 | :param text_num_chars: The number of characters in the text that will be printed at the box. Defaults to 1. 443 | :type text_num_chars: int, optional 444 | :param contin: Whether to continue printing from the current position of the cursor. Defaults to False. 445 | :type contin: bool, optional 446 | :param force_col: Whether to force the cursor to move to the specified column. Defaults to False. 447 | :type force_col: bool, optional 448 | :return: The coordinates of the box. 449 | :rtype: tuple 450 | """ 451 | if row_num < 1 or col_num < 1: # do not care about exceeding col_num 452 | raise ValueError 453 | elif row_num > self.num_rows: 454 | ic( 455 | f"row {row_num} > max row {self.num_rows}, using row {self.num_rows} instead" 456 | ) 457 | row_num = self.num_rows 458 | max_row_num = ( 459 | self.num_rows - text_num_lines + 1 460 | ) # maximum row that can be permitted 461 | min_col_num = self.__col_in_row[row_num] 462 | 463 | if not contin: 464 | num_blank_rows = 0 465 | first_blank_row = self.num_rows + 1 # all rows are filled 466 | for i in range(self.num_rows, row_num - 1, -1): 467 | if self.__col_in_row[i] == 1: 468 | first_blank_row = i 469 | num_blank_rows += 1 470 | else: 471 | break 472 | ic(first_blank_row, num_blank_rows) # debug 473 | 474 | if row_num > max_row_num: 475 | ic(f"{text_num_lines} lines cannot be accomodated at {row_num}") 476 | ic(f"Maximum possible is {max_row_num}") 477 | if first_blank_row < max_row_num: # FIXME: needed ? 478 | ic("NEEDED!") # debug 479 | sys.exit(1) 480 | scroll_times = text_num_lines - num_blank_rows 481 | ic(scroll_times) 482 | self.scroll_up(scroll_times) 483 | row_num = self.curr_row 484 | else: 485 | row_num = max_row_num # enough space to print; no need to scroll 486 | 487 | elif first_blank_row > row_num: 488 | scroll_times = first_blank_row - row_num 489 | ic(scroll_times) 490 | self.scroll_up(scroll_times) 491 | else: 492 | if col_num < min_col_num and not force_col: 493 | ic(f"{text_num_chars} chars cannot be accomodated at column {col_num}") 494 | col_num = self.__col_in_row[row_num] 495 | self.curr_row, self.curr_col = row_num, col_num 496 | ic(self.curr_row, self.curr_col) # debug 497 | 498 | x1 = self.__xpad + (col_num - 1) * self.__font_width 499 | y1 = self.__ypad + (row_num - 1) * (self.__font_height + self.__line_spacing) 500 | x2 = self.__xpad + col_num * self.__font_width 501 | y2 = self.__ypad + row_num * (self.__font_height + self.__line_spacing) 502 | return x1, y1, x2, y2 503 | 504 | def gen_text( 505 | self, 506 | text: str | list, 507 | row_num: int, 508 | col_num: int = 1, 509 | count: int = 1, 510 | prompt: bool = False, 511 | contin: bool = False, 512 | ) -> None: 513 | """Generate text on the Terminal object. 514 | 515 | This method generates text on the Terminal object. The text is specified by `text`, and the position of the text is specified by `row_num` and `col_num`. The method also takes into account whether to generate a prompt after the text (`prompt`), whether to continue printing from the current position of the cursor (`contin`), and the number of times to generate the text (`count`). 516 | If there is a single line of text, the default behaviour to position the cursor is at the end of the same line. 517 | If there are multiple lines of text, the default behaviour to position the cursor is at the beginning of the next line. 518 | Prompt is generated only if there are multiple lines of text. 519 | 520 | :param text: The text to generate. If a string, the text is split into lines. If a list, each element is treated as a line of text. 521 | :type text: str | list 522 | :param row_num: The row number where the text starts. 523 | :type row_num: int 524 | :param col_num: The column number where the text starts. Defaults to 1. 525 | :type col_num: int, optional 526 | :param count: The number of times to generate the text. Defaults to 1. 527 | :type count: int, optional 528 | :param prompt: Whether to generate a prompt after the text. Defaults to False. 529 | :type prompt: bool, optional 530 | :param contin: Whether to continue printing from the current position of the cursor. Defaults to False. 531 | :type contin: bool, optional 532 | """ 533 | if prompt and contin: # FIXME: why ? 534 | print("ERROR: Both prompt and contin can't be simultaneously True") # debug 535 | sys.exit(1) 536 | 537 | if isinstance(text, str): 538 | text_lines = text.splitlines() 539 | text_num_lines = len(text_lines) 540 | else: 541 | text_lines = text 542 | text_num_lines = len(text) 543 | 544 | ansi_escape_pattern = re.compile( 545 | r"(\\x1b\[\d+(?:;\d+)*m|\x1b\[\d+(?:;\d+)*m)" 546 | ) # match ANSI color mode escape codes 547 | color_code_pattern = re.compile( 548 | r"\\x1b\[(\d+)(?:;(\d+))*m|\x1b\[(\d+)(?:;(\d+))*m" 549 | ) # match only color codes 550 | for i in range(text_num_lines): # for each line 551 | self.cursor_to_box( 552 | row_num + i, 553 | col_num, 554 | 1, 555 | 1, 556 | contin, 557 | ) # initialize position to check contin for each line 558 | 559 | line = text_lines[i] # single line 560 | words = [word for word in re.split(ansi_escape_pattern, line) if word] 561 | for word in words: # for each word in line 562 | if re.match(ansi_escape_pattern, word): # if ANSI escape sequence 563 | codes = [ 564 | code 565 | for _ in re.findall(color_code_pattern, word) 566 | for code in _ 567 | if code 568 | ] 569 | for code in codes: 570 | if code == "0": # reset to default 571 | self.set_txt_color() 572 | self.set_bg_color() 573 | continue 574 | else: 575 | code_info = ConvertAnsiEscape.convert(code) 576 | if code_info: 577 | if code_info.oper == "txt_color": 578 | self.set_txt_color(code_info.data) 579 | continue 580 | if code_info.oper == "bg_color": 581 | self.set_bg_color(code_info.data) 582 | continue 583 | else: # if normal word 584 | text_num_chars = len(word) 585 | x1, y1, _, _ = self.cursor_to_box( 586 | row_num + i, 587 | col_num, 588 | 1, 589 | text_num_chars, 590 | True, # contin=True since words in same line 591 | ) 592 | draw = ImageDraw.Draw(self.__frame) 593 | _, _, rx2, _ = draw.textbbox( 594 | (x1, y1), word, self.__font 595 | ) # change bg_color 596 | draw.rectangle( 597 | (x1, y1, rx2, y1 + self.__font_height), self.__bg_color 598 | ) 599 | draw.text((x1, y1), word, self.__txt_color, self.__font) 600 | self.curr_col += len(word) 601 | self.__col_in_row[self.curr_row] = self.curr_col 602 | ic(self.curr_row, self.curr_col) # debug 603 | if self.__check_multiline(text_lines): 604 | self.cursor_to_box( 605 | self.curr_row + 1, 1, 1, 1, contin 606 | ) # move down by 1 row only if multiline 607 | 608 | if prompt and self.__check_multiline( 609 | text_lines 610 | ): # only generate prompt if multiline 611 | self.gen_prompt(self.curr_row, 1, 1) 612 | 613 | draw = ImageDraw.Draw(self.__frame) 614 | for _ in range(count): 615 | if self.__show_cursor: 616 | cx1, cy1, _, _ = self.cursor_to_box( 617 | self.curr_row, self.curr_col, 1, 1, contin=True 618 | ) # no unnecessary scroll 619 | draw.text( 620 | (cx1, cy1), str(self.__cursor), self.__def_txt_color, self.__font 621 | ) 622 | self.__gen_frame(self.__frame) 623 | if self.__show_cursor: 624 | cx1, cy1, _, _ = self.cursor_to_box( 625 | self.curr_row, self.curr_col, 1, 1, contin=True 626 | ) # no unnecessary scroll 627 | blank_box_image = Image.new( 628 | "RGB", 629 | (self.__font_width, self.__font_height + self.__line_spacing), 630 | self.__def_bg_color, 631 | ) 632 | self.__frame.paste(blank_box_image, (cx1, cy1)) 633 | if ( 634 | self.__blink_cursor and self.__frame_count % (self.__fps // 3) == 0 635 | ): # alter cursor such that blinks every one-third second 636 | self.__alter_cursor() 637 | 638 | def gen_typing_text( 639 | self, 640 | text: str, 641 | row_num: int, 642 | col_num: int = 1, 643 | contin: bool = False, 644 | speed: int = 0, 645 | ) -> None: 646 | """Generate typing text simulation on the Terminal object. 647 | 648 | This method generates typing text on the Terminal object. The text is specified by `text`, and the position of the text is specified by `row_num` and `col_num`. The method also takes into account whether to continue printing from the current position of the cursor `contin`, and the speed of typing `speed`. 649 | 650 | Speed configuration: 651 | 0 - random - random frame count 652 | 1 - fast - 1 frame count 653 | 2 - medium - 2 frame count 654 | 3 - slow - 3 frame count 655 | 656 | :param text: The text to generate. If a string, the text is split into words and each word is printed character by character. 657 | :type text: str 658 | :param row_num: The row number where the text starts. 659 | :type row_num: int 660 | :param col_num: The column number where the text starts. Defaults to 1. 661 | :type col_num: int, optional 662 | :param contin: Whether to continue printing from the current position of the cursor. Defaults to False. 663 | :type contin: bool, optional 664 | :param speed: The speed of typing. Can be 0 (random), 1 (fast), 2 (medium), or 3 (slow). Defaults to 0. 665 | :type speed: int, optional 666 | """ 667 | ansi_escape_pattern = re.compile( 668 | r"(\\x1b\[\d+(?:;\d+)*m|\x1b\[\d+(?:;\d+)*m)" 669 | ) # match ANSI color mode escape codes 670 | if not contin: 671 | self.cursor_to_box(row_num, col_num, 1, 1, contin) 672 | words = [word for word in re.split(ansi_escape_pattern, text) if word] 673 | for word in words: 674 | if re.match(ansi_escape_pattern, word): 675 | self.gen_text(word, row_num, self.__col_in_row[row_num], 0, False, True) 676 | else: 677 | for char in word: 678 | count = speed if speed in [1, 2, 3] else random.choice([1, 2, 3]) 679 | self.gen_text( 680 | char, row_num, self.__col_in_row[row_num], count, False, True 681 | ) 682 | 683 | def set_prompt(self, prompt: str) -> None: 684 | """Set the prompt for the Terminal object. 685 | 686 | This method sets the prompt for the Terminal object. The prompt is specified by `prompt`. 687 | 688 | :param prompt: The prompt to set. 689 | :type prompt: str 690 | """ 691 | self.__prompt = prompt 692 | 693 | def gen_prompt(self, row_num: int, col_num: int = 1, count: int = 1) -> None: 694 | """Generate a prompt on the Terminal object. 695 | 696 | This method generates a prompt on the Terminal object. The position of the prompt is specified by `row_num` and `col_num`, and the number of times to generate the prompt is specified by `count`. Before generating the prompt, the method clones the current frame and ensures that the cursor is visible. After generating the prompt, the method restores the original state of the cursor. 697 | 698 | :param row_num: The row number where the prompt starts. 699 | :type row_num: int 700 | :param col_num: The column number where the prompt starts. Defaults to 1. 701 | :type col_num: int, optional 702 | :param count: The number of times to generate the prompt. Defaults to 1. 703 | :type count: int, optional 704 | """ 705 | self.clone_frame(1) # wait a bit before printing new prompt 706 | orig_cursor_state = self.__show_cursor 707 | self.toggle_show_cursor(True) 708 | self.gen_text( 709 | self.__prompt, row_num, col_num, count, False, False 710 | ) # generate prompt right after printed text, i.e. 1 line below 711 | self.__show_cursor = orig_cursor_state 712 | 713 | def scroll_up(self, count: int = 1) -> None: 714 | """Scroll up the Terminal object. 715 | 716 | This method scrolls up the Terminal object a specified number of times. The number of times to scroll up is specified by `count`. 717 | 718 | :param count: The number of times to scroll up. Defaults to 1. 719 | :type count: int, optional 720 | """ 721 | for _ in range(count): 722 | cropped_frame = self.__frame.crop( 723 | ( 724 | 0, 725 | self.__font_height + self.__line_spacing, 726 | self.__width, 727 | self.__height, 728 | ) 729 | ) # make room for 1 extra line (__font_height + __line_spacing) 730 | self.__frame = Image.new( 731 | "RGB", (self.__width, self.__height), self.__def_bg_color 732 | ) 733 | self.__frame.paste(cropped_frame, (0, 0)) 734 | self.curr_row -= 1 # move cursor to where it was 735 | 736 | keys = list(self.__col_in_row.keys()) 737 | values = list(self.__col_in_row.values()) 738 | shifted_values = values[1:] + [1] 739 | shifted_dict = dict(zip(keys, shifted_values)) 740 | self.__col_in_row = shifted_dict 741 | ic(self.curr_row, self.curr_col) 742 | 743 | def delete_row(self, row_num: int, col_num: int = 1) -> None: 744 | """Delete a row in the Terminal object. 745 | 746 | This method deletes a row in the Terminal object. The row is specified by `row_num`, and the column where the deletion starts is specified by `col_num`. 747 | 748 | :param row_num: The row number to delete. 749 | :type row_num: int 750 | :param col_num: The column number where the deletion starts. Defaults to 1. 751 | :type col_num: int, optional 752 | """ 753 | x1, y1, _, _ = self.cursor_to_box( 754 | row_num, col_num, 1, 1, True, force_col=True 755 | ) # continue = True; do not scroll up 756 | self.__col_in_row[row_num] = col_num 757 | blank_line_image = Image.new( 758 | "RGB", 759 | (self.__width - x1, self.__font_height + self.__line_spacing), 760 | self.__bg_color, 761 | ) 762 | self.__frame.paste(blank_line_image, (x1, y1)) 763 | ic(f"Deleted row {row_num} starting at col {col_num}") 764 | 765 | def paste_image( 766 | self, 767 | image_file: str, 768 | row_num: int, 769 | col_num: int = 1, 770 | size_multiplier: float = 1, 771 | ) -> None: 772 | """Paste an image onto the Terminal object. 773 | 774 | This method pastes an image onto the Terminal object. The image is specified by `image_file`, and the position of the image is specified by `row_num` and `col_num`. The method also takes into account a size multiplier `size_multiplier` to resize (with same aspect ratio) the image before pasting it. 775 | 776 | :param image_file: The path to the image file to paste. 777 | :type image_file: str 778 | :param row_num: The row number where the image starts. 779 | :type row_num: int 780 | :param col_num: The column number where the image starts. Defaults to 1. 781 | :type col_num: int, optional 782 | :param size_multiplier: The multiplier by which to resize the image. Defaults to 1. 783 | :type size_multiplier: float, optional 784 | """ 785 | x1, y1, _, _ = self.cursor_to_box(row_num, col_num, 1, 1, True, True) 786 | with Image.open(image_file) as image: 787 | image_width, image_height = image.size 788 | image = image.resize( 789 | ( 790 | int(image_width * size_multiplier), 791 | int(image_height * size_multiplier), 792 | ) 793 | ) 794 | rows_covered = ceil( 795 | image.height / (self.__font_height + self.__line_spacing) 796 | ) 797 | cols_covered = ceil(image.width / (self.__font_width)) + 1 798 | if ( 799 | row_num + rows_covered > self.num_rows 800 | or col_num + cols_covered > self.num_cols 801 | ): 802 | print("WARNING: Image exceeds frame dimensions") 803 | else: 804 | for i in range(rows_covered): 805 | self.__col_in_row[row_num + i] = cols_covered 806 | self.image_col = col_num + cols_covered # helper for scripts 807 | self.__frame.paste(image, (x1, y1)) 808 | self.__gen_frame(self.__frame) 809 | 810 | def set_fps(self, fps: float) -> None: 811 | """Set the frames per second (fps) for the GIF to be generated. 812 | 813 | This method sets the frames per second (fps) for the GIF to be generated. The fps is specified by `fps`. 814 | 815 | :param fps: The frames per second to set. 816 | :type fps: float 817 | """ 818 | self.__fps = fps 819 | 820 | def set_loop_count(self, loop_count: int) -> None: 821 | """Set the loop count for GIF to be generated. 822 | 823 | This method sets the loop count for the GIF to be generated. Specifications for the loop number 824 | are given by ffmpeg as follows: 825 | -1: No-loop (stop after first playback) 826 | 0: Infinite loop 827 | 1..65535: Loop n times up to a maximum of 65535 828 | 829 | :param loop_count: The number of loops in the GIF to be generated. 830 | :type loop_count: int 831 | """ 832 | 833 | def limit(n: int, lower: int, upper: int): 834 | return min(max(n, lower), upper) 835 | 836 | self.__loop_count = limit(loop_count, -1, 65535) 837 | 838 | def gen_gif(self) -> None: 839 | """Generate a GIF from the frames. 840 | 841 | This method generates a GIF from the frames. The method uses the `ffmpeg` command to generate the GIF, with the frames per second (fps) set to the fps specified in the Terminal object. The generated GIF is saved with the name specified by `output_gif_name`. 842 | """ 843 | os.system( 844 | f"ffmpeg -hide_banner -loglevel error -r {self.__fps} -i '{frame_folder_name}/{frame_base_name}%d.png' -loop {self.__loop_count} -filter_complex '[0:v] split [a][b];[a] palettegen [p];[b][p] paletteuse' {output_gif_name}.gif" 845 | ) 846 | print( 847 | f"INFO: Generated {output_gif_name}.gif approximately {round(self.__frame_count / self.__fps, 2)}s long" 848 | ) 849 | --------------------------------------------------------------------------------