File indexing completed on 2024-12-01 08:16:20
0001 """ 0002 Module containing classes for common tasks 0003 """ 0004 0005 import os 0006 import re 0007 import shlex 0008 0009 # SPDX-FileCopyrightText: 2020 Jonah BrĂ¼chert <jbb@kaidan.im> 0010 # 0011 # SPDX-License-Identifier: GPL-2.0-or-later 0012 import shutil 0013 import subprocess 0014 import sys 0015 from datetime import datetime, timezone 0016 from enum import Enum, auto 0017 from typing import List, Optional, Final 0018 from urllib.parse import ParseResult, urlparse 0019 0020 from git import Repo 0021 from git.exc import InvalidGitRepositoryError 0022 0023 TIME_STR_REGEX = r"^([0-9]+mo)?([0-9]+w)?([0-9]+d)?([0-9]+h)?([0-9]+m)?$" 0024 0025 0026 def is_valid_time_str(time_str: str) -> bool: 0027 """ 0028 Returns True if a given string matches the time tracking convention of Gitlab. 0029 See: https://docs.gitlab.com/ee/user/project/time_tracking.html 0030 """ 0031 return bool(re.match(TIME_STR_REGEX, time_str)) 0032 0033 0034 def removesuffix(string: str, suffix: str) -> str: 0035 """ 0036 Compatiblity function for python < 3.9 0037 """ 0038 if sys.version_info >= (3, 9): 0039 return string.removesuffix(suffix) 0040 0041 if string.endswith(suffix): 0042 return string[: -len(suffix)] 0043 0044 return string 0045 0046 0047 class LogType(Enum): 0048 """ 0049 Enum representing the type of log message 0050 """ 0051 0052 INFO = auto() 0053 WARNING = auto() 0054 ERROR = auto() 0055 0056 0057 class TextFormatting: # pylint: disable=too-few-public-methods 0058 """ 0059 Structure containing constants for working with text formatting 0060 """ 0061 0062 PURPLE: Final[str] = "\033[0;95m" 0063 CYAN: Final[str] = "\033[0;36m" 0064 DARKCYAN: Final[str] = "\033[0;96m" 0065 BLUE: Final[str] = "\033[0;34m" 0066 GREEN: Final[str] = "\033[0;32m" 0067 YELLOW: Final[str] = "\033[0;33m" 0068 RED: Final[str] = "\033[0;31m" 0069 LIGHTRED: Final[str] = "\033[1;31m" 0070 BOLD: Final[str] = "\033[1m" 0071 UNDERLINE: Final[str] = "\033[4m" 0072 END: Final[str] = "\033[0m" 0073 0074 @staticmethod 0075 def red(s: str) -> str: 0076 return f"{TextFormatting.RED}{s}{TextFormatting.END}" 0077 0078 @staticmethod 0079 def green(s: str) -> str: 0080 return f"{TextFormatting.GREEN}{s}{TextFormatting.END}" 0081 0082 0083 class Utils: 0084 """ 0085 This class contains static methods for common tasks 0086 """ 0087 0088 @staticmethod 0089 def str_id_for_url(url: str) -> str: 0090 """ 0091 Returns the unencoded string id for a repository 0092 """ 0093 normalized_url: str = Utils.normalize_url(url) 0094 normalized_url = removesuffix(normalized_url, ".git") 0095 normalized_url = removesuffix(normalized_url, "/") 0096 0097 repository_url: ParseResult = urlparse(normalized_url) 0098 return repository_url.path[1:] 0099 0100 @staticmethod 0101 def log(log_type: LogType, *message: str) -> None: 0102 """ 0103 Prints a message in a colorful and consistent way 0104 """ 0105 prefix = TextFormatting.BOLD 0106 if log_type == LogType.INFO: 0107 prefix += "Info" 0108 elif log_type == LogType.WARNING: 0109 prefix += TextFormatting.YELLOW + "Warning" + TextFormatting.END 0110 elif log_type == LogType.ERROR: 0111 prefix += TextFormatting.RED + "Error" + TextFormatting.END 0112 0113 prefix += TextFormatting.END 0114 0115 if len(prefix) > 0: 0116 prefix += ":" 0117 0118 print(prefix, *message) 0119 0120 @staticmethod 0121 def normalize_url(url: str) -> str: 0122 """ 0123 Creates a correctly parsable url from a git remote url. 0124 Git remote urls can also be written in scp syntax, which is technically not a real url. 0125 0126 Example: git@invent.kde.org:KDE/kaidan becomes ssh://git@invent.kde.org/KDE/kaidan 0127 """ 0128 result = urlparse(url) 0129 0130 # url is already fine 0131 if result.scheme != "": 0132 return url 0133 0134 if "@" in url and ":" in url: 0135 return "ssh://" + url.replace(":", "/") 0136 0137 Utils.log(LogType.ERROR, "Invalid url", url) 0138 sys.exit(1) 0139 0140 @staticmethod 0141 def ssh_url_from_http(url: str) -> str: 0142 """ 0143 Creates an ssh url from a http url 0144 0145 :return ssh url 0146 """ 0147 0148 return url.replace("https://", "ssh://git@").replace("http://", "ssh://git@") 0149 0150 @staticmethod 0151 def gitlab_instance_url(repository: str) -> str: 0152 """ 0153 returns the gitlab instance url of a git remote url 0154 """ 0155 # parse url 0156 repository_url: ParseResult = urlparse(repository) 0157 0158 # Valid url 0159 if repository_url.scheme != "" and repository_url.path != "": 0160 # If the repository is using some kind of http, can know whether to use http or https 0161 if "http" in repository_url.scheme: 0162 if repository_url.scheme and repository_url.hostname: 0163 return repository_url.scheme + "://" + repository_url.hostname 0164 0165 # Else assume https. 0166 # redirects don't work according to 0167 # https://python-gitlab.readthedocs.io/en/stable/api-usage.html. 0168 if repository_url.hostname: 0169 return "https://" + repository_url.hostname 0170 0171 # non valid url (probably scp syntax) 0172 if "@" in repository and ":" in repository: 0173 # create url in form of ssh://git@github.com/KDE/kaidan 0174 repository_url = urlparse("ssh://" + repository.replace(":", "/")) 0175 0176 if repository_url.hostname: 0177 return "https://" + repository_url.hostname 0178 0179 # If everything failed, exit 0180 Utils.log(LogType.ERROR, "Failed to detect GitLab instance url") 0181 sys.exit(1) 0182 0183 @staticmethod 0184 def get_cwd_repo() -> Repo: 0185 """ 0186 Creates a Repo object from one of the parent directories of the current directories. 0187 If it can not find a git repository, an error is shown. 0188 """ 0189 try: 0190 return Repo(Utils.find_dotgit(os.getcwd())) 0191 except InvalidGitRepositoryError: 0192 Utils.log(LogType.ERROR, "Current directory is not a git repository") 0193 sys.exit(1) 0194 0195 @staticmethod 0196 def editor() -> List[str]: 0197 """ 0198 return prefered user editor using git configuration 0199 """ 0200 repo = Utils.get_cwd_repo() 0201 config = repo.config_reader() 0202 editor: str = config.get_value("core", "editor", "") 0203 if not editor: 0204 if "EDITOR" in os.environ: 0205 editor = os.environ["EDITOR"] 0206 elif "VISUAL" in os.environ: 0207 editor = os.environ["VISUAL"] 0208 elif shutil.which("editor"): 0209 editor = "editor" 0210 else: 0211 editor = "vi" 0212 0213 return shlex.split(editor) 0214 0215 @staticmethod 0216 def xdg_open(path: str) -> None: 0217 """ 0218 Open path with xdg-open 0219 :param path: path to open 0220 """ 0221 subprocess.call(("xdg-open", path)) 0222 0223 @staticmethod 0224 def ask_bool(question: str) -> bool: 0225 """ 0226 Ask a yes or no question 0227 :param question: text for the question 0228 :return: whether the user answered yes 0229 """ 0230 answer: str = input("{} [y/n] ".format(question)) 0231 if answer == "y": 0232 return True 0233 0234 return False 0235 0236 @staticmethod 0237 def find_dotgit(path: str) -> Optional[str]: 0238 """ 0239 Finds the parent directory containing .git, and returns it 0240 :param: path to start climbing from 0241 :return: resulting path 0242 """ 0243 abspath = os.path.abspath(path) 0244 0245 if ".git" in os.listdir(abspath): 0246 return abspath 0247 0248 parent_dir: str = os.path.abspath(abspath + os.path.sep + os.path.pardir) 0249 0250 # No parent dir exists, we are at the root filesystem 0251 if os.path.samefile(parent_dir, path): 0252 return None 0253 0254 return Utils.find_dotgit(parent_dir) 0255 0256 @staticmethod 0257 def pretty_date(date_string: str, now: datetime = datetime.now(timezone.utc)) -> str: 0258 """Transform an ISO-8601 date-string and transform it into a human readable format. 0259 Taken almost verbatim from: 0260 https://stackoverflow.com/questions/1551382/user-friendly-time-format-in-python 0261 """ 0262 time = datetime.strptime(date_string, "%Y-%m-%dT%H:%M:%S.%fZ") 0263 try: 0264 diff = now - time 0265 except TypeError: 0266 # We most likely tried to subtract an offset-naive and an offset-aware date 0267 now = now.replace(tzinfo=None) 0268 time = time.replace(tzinfo=None) 0269 diff = now - time 0270 0271 second_diff = diff.seconds 0272 day_diff = diff.days 0273 0274 if day_diff < 0: 0275 return "" 0276 0277 if day_diff == 0: 0278 if second_diff < 10: 0279 return "just now" 0280 if second_diff < 60: 0281 return str(second_diff) + " seconds ago" 0282 if second_diff < 120: 0283 return "a minute ago" 0284 if second_diff < 3600: 0285 return str(second_diff // 60) + " minutes ago" 0286 if second_diff < 7200: 0287 return "an hour ago" 0288 if second_diff < 86400: 0289 return str(second_diff // 3600) + " hours ago" 0290 if day_diff == 1: 0291 return "Yesterday" 0292 if day_diff < 7: 0293 return str(day_diff) + " days ago" 0294 if day_diff < 31: 0295 return str(day_diff // 7) + " weeks ago" 0296 if day_diff < 365: 0297 return str(day_diff // 30) + " months ago" 0298 return str(day_diff // 365) + " years ago" 0299 0300 @staticmethod 0301 def pretty_time_delta(seconds: int = 0) -> str: 0302 """ 0303 Pretty print a given timedelta in seconds human readable. 0304 """ 0305 seconds = abs(seconds) # Make seconds unsigned 0306 days, seconds = divmod(seconds, 86400) 0307 hours, seconds = divmod(seconds, 3600) 0308 minutes, seconds = divmod(seconds, 60) 0309 if days: 0310 return "%dd %dh %dm %ds" % (days, hours, minutes, seconds) 0311 if hours: 0312 return "%dh %dm %ds" % (hours, minutes, seconds) 0313 if minutes: 0314 return "%dm %ds" % (minutes, seconds) 0315 return "%ds" % (seconds,) 0316 0317 @staticmethod 0318 def get_default_branch(repo: Repo) -> str: 0319 try: 0320 return repo.remotes.origin.refs["HEAD"].ref.remote_head 0321 except: 0322 return "master"