File indexing completed on 2024-04-21 05:41:18

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"