From f4b2e3d4091799a29332a97ad74b7c191bdef000 Mon Sep 17 00:00:00 2001 From: Theo Rozier Date: Sat, 13 Jan 2024 12:52:00 +0100 Subject: [PATCH 01/30] Added note about yggdrasil auth deprecation --- portablemc/auth.py | 14 ++++++++++---- portablemc/cli/__init__.py | 7 +++++-- portablemc/cli/lang.py | 1 + 3 files changed, 16 insertions(+), 6 deletions(-) diff --git a/portablemc/auth.py b/portablemc/auth.py index 197b4fb9..080f6f19 100644 --- a/portablemc/auth.py +++ b/portablemc/auth.py @@ -166,10 +166,16 @@ def request(cls, req: str, payload: dict, raise_error: bool = True) -> Tuple[int content_type="application/json") return res.status, res.json() except HttpError as error: - if raise_error: - raise AuthError(error.res.json()["errorMessage"]) - else: - return error.res.status, error.res.json() + try: + if raise_error: + raise AuthError(error.res.json()["errorMessage"]) + else: + return error.res.status, error.res.json() + except json.JSONDecodeError: + if raise_error: + raise AuthError("invalid json") + else: + return error.res.status, {} class MicrosoftAuthSession(AuthSession): diff --git a/portablemc/cli/__init__.py b/portablemc/cli/__init__.py index 71c7451d..1a5b1905 100644 --- a/portablemc/cli/__init__.py +++ b/portablemc/cli/__init__.py @@ -556,6 +556,10 @@ def prompt_authenticate(ns: RootNs, email: str, caching: bool, service: str, ano ns.auth_database.load() + if service == "yggdrasil": + ns.out.task("WARN", "auth.yggdrasil.deprecated") + ns.out.finish() + task_text = f"auth.{service}" email_text = anonymize_email(email) if anonymise else email @@ -591,8 +595,7 @@ def prompt_authenticate(ns: RootNs, email: str, caching: bool, service: str, ano session = prompt_yggdrasil_authenticate(ns, email) except AuthError as error: - ns.out.task("FAILED", None) - ns.out.task(None, "auth.error", message=str(error)) + ns.out.task("FAILED", "auth.error", message=str(error)) ns.out.finish() return None diff --git a/portablemc/cli/lang.py b/portablemc/cli/lang.py index 43df754e..d083fa20 100644 --- a/portablemc/cli/lang.py +++ b/portablemc/cli/lang.py @@ -220,6 +220,7 @@ def get(key: str, **kwargs) -> str: # Auth Yggdrasil "auth.yggdrasil": "Authenticating {email} with Mojang...", "auth.yggdrasil.enter_password": "Password: ", + "auth.yggdrasil.deprecated": "Mojang authentication is deprecated.", # Auth Microsoft "auth.microsoft": "Authenticating {email} with Microsoft...", "auth.microsoft.no_browser": "Failed to open Microsoft login page, no web browser found on your system.", From f410c809ca9e2d344f44734713f3a24cecf5a9de Mon Sep 17 00:00:00 2001 From: Theo Rozier Date: Sat, 13 Jan 2024 12:52:48 +0100 Subject: [PATCH 02/30] Improved yggdrasil deprecation lang --- portablemc/cli/lang.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/portablemc/cli/lang.py b/portablemc/cli/lang.py index d083fa20..92b7ec30 100644 --- a/portablemc/cli/lang.py +++ b/portablemc/cli/lang.py @@ -220,7 +220,7 @@ def get(key: str, **kwargs) -> str: # Auth Yggdrasil "auth.yggdrasil": "Authenticating {email} with Mojang...", "auth.yggdrasil.enter_password": "Password: ", - "auth.yggdrasil.deprecated": "Mojang authentication is deprecated.", + "auth.yggdrasil.deprecated": "Mojang authentication is deprecated and does not work anymore.", # Auth Microsoft "auth.microsoft": "Authenticating {email} with Microsoft...", "auth.microsoft.no_browser": "Failed to open Microsoft login page, no web browser found on your system.", From 86b97c9b441230ab52e5b0612b817ef318235968 Mon Sep 17 00:00:00 2001 From: Theo Rozier Date: Sat, 13 Jan 2024 14:55:49 +0100 Subject: [PATCH 03/30] Working on microsoft auth without web browser (wip) --- portablemc/cli/__init__.py | 162 +++++++++++++++++++------------------ portablemc/cli/lang.py | 2 + portablemc/cli/parse.py | 15 ++-- 3 files changed, 96 insertions(+), 83 deletions(-) diff --git a/portablemc/cli/__init__.py b/portablemc/cli/__init__.py index 1a5b1905..7bfb2ad4 100644 --- a/portablemc/cli/__init__.py +++ b/portablemc/cli/__init__.py @@ -12,7 +12,7 @@ import socket import sys -from .parse import register_arguments, RootNs, SearchNs, StartNs, LoginNs, LogoutNs +from .parse import register_arguments, RootNs, SearchNs, StartNs, LoginNs, LogoutNs, AuthBaseNs from .util import format_locale_date, format_time, format_number, anonymize_email from .output import Output, HumanOutput, MachineOutput, OutputTable from .lang import get as _, lang @@ -326,7 +326,7 @@ def cmd_start(ns: StartNs): version.fixes[Version.FIX_LWJGL] = ns.lwjgl if ns.login is not None: - version.auth_session = prompt_authenticate(ns, ns.login, not ns.temp_login, ns.auth_service, ns.auth_anonymize) + version.auth_session = prompt_authenticate(ns, ns.login, not ns.temp_login, ns.auth_anonymize) if version.auth_session is None: sys.exit(EXIT_FAILURE) else: @@ -468,7 +468,7 @@ def cmd_start_handler(ns: StartNs, kind: str, parts: List[str]) -> Optional[Vers def cmd_login(ns: LoginNs): - session = prompt_authenticate(ns, ns.email_or_username, True, ns.auth_service) + session = prompt_authenticate(ns, ns.email_or_username, True) if session is not None: ns.out.task("INFO", "login.tip.remember_start_login", email=ns.email_or_username) ns.out.finish() @@ -542,13 +542,15 @@ def cmd_show_lang(ns: RootNs): table.print() -def prompt_authenticate(ns: RootNs, email: str, caching: bool, service: str, anonymise: bool = False) -> Optional[AuthSession]: +def prompt_authenticate(ns: AuthBaseNs, email: str, caching: bool, anonymise: bool = False) -> Optional[AuthSession]: """Prompt the user to login using the given email (or legacy username) for specific service (Microsoft or Yggdrasil) and return the :class:`AuthSession` if successful, None otherwise. This function handles task printing and all exceptions are caught internally. """ + service = ns.auth_service + session_class = { "microsoft": MicrosoftAuthSession, "yggdrasil": YggdrasilAuthSession, @@ -624,7 +626,7 @@ def prompt_yggdrasil_authenticate(ns: RootNs, email_or_username: str) -> Optiona return YggdrasilAuthSession.authenticate(ns.auth_database.get_client_id(), email_or_username, password) -def prompt_microsoft_authenticate(ns: RootNs, email: str) -> Optional[MicrosoftAuthSession]: +def prompt_microsoft_authenticate(ns: AuthBaseNs, email: str) -> Optional[MicrosoftAuthSession]: from .. import LAUNCHER_NAME, LAUNCHER_VERSION from http.server import HTTPServer, BaseHTTPRequestHandler @@ -641,93 +643,99 @@ def prompt_microsoft_authenticate(ns: RootNs, email: str) -> Optional[MicrosoftA nonce = uuid4().hex auth_url = MicrosoftAuthSession.get_authentication_url(app_id, code_redirect_uri, email, nonce) - if not webbrowser.open(auth_url): - ns.out.finish() - ns.out.task("FAILED", "auth.microsoft.no_browser") - ns.out.finish() - return None - - class AuthServer(HTTPServer): - - def __init__(self): - super().__init__(("", server_port), RequestHandler) - self.timeout = 0.5 - self.ms_auth_done = False - self.ms_auth_id_token: Optional[str] = None - self.ms_auth_code: Optional[str] = None - - class RequestHandler(BaseHTTPRequestHandler): - server_version = f"{LAUNCHER_NAME}/{LAUNCHER_VERSION}" + if ns.auth_no_browser or not webbrowser.open(auth_url): - def __init__(self, request, client_address: Tuple[str, int], auth_server: AuthServer) -> None: - super().__init__(request, client_address, auth_server) + ns.out.finish() + ns.out.task("INFO", "auth.microsoft.no_browser_fallback") + ns.out.finish() + ns.out.print(auth_url + "\n") - def log_message(self, _format: str, *args: Any): - return + return None + + else: - def send_auth_response(self, msg: str): - self.end_headers() - self.wfile.write("{}\n\n{}".format(msg, _('auth.microsoft.close_tab_and_return') if cast(AuthServer, self.server).ms_auth_done else "").encode()) - self.wfile.flush() + class AuthServer(HTTPServer): + + def __init__(self): + super().__init__(("", server_port), RequestHandler) + self.timeout = 0.5 + self.ms_auth_done = False + self.ms_auth_id_token: Optional[str] = None + self.ms_auth_code: Optional[str] = None + + class RequestHandler(BaseHTTPRequestHandler): + + server_version = f"{LAUNCHER_NAME}/{LAUNCHER_VERSION}" + + def __init__(self, request, client_address: Tuple[str, int], auth_server: AuthServer) -> None: + super().__init__(request, client_address, auth_server) + + def log_message(self, _format: str, *args: Any): + return + + def send_auth_response(self, msg: str): + self.end_headers() + self.wfile.write("{}\n\n{}".format(msg, _('auth.microsoft.close_tab_and_return') if cast(AuthServer, self.server).ms_auth_done else "").encode()) + self.wfile.flush() + + def do_POST(self): + if self.path.startswith("/code") and self.headers.get_content_type() == "application/x-www-form-urlencoded": + content_length = int(self.headers["Content-Length"]) + qs = urllib.parse.parse_qs(self.rfile.read(content_length).decode()) + auth_server = cast(AuthServer, self.server) + if "code" in qs and "id_token" in qs: + self.send_response(307) + # We log out the user directly after authorization, this just clear the browser cache to allow + # another user to authenticate with another email after. This doesn't invalid the access token. + self.send_header("Location", MicrosoftAuthSession.get_logout_url(app_id, exit_redirect_uri)) + auth_server.ms_auth_id_token = qs["id_token"][0] + auth_server.ms_auth_code = qs["code"][0] + self.send_auth_response("Redirecting...") + elif "error" in qs: + self.send_response(400) + auth_server.ms_auth_done = True + self.send_auth_response("Error: {} ({}).".format(qs["error_description"][0], qs["error"][0])) + else: + self.send_response(404) + self.send_auth_response("Missing parameters.") + else: + self.send_response(404) + self.send_auth_response("Unexpected page.") - def do_POST(self): - if self.path.startswith("/code") and self.headers.get_content_type() == "application/x-www-form-urlencoded": - content_length = int(self.headers["Content-Length"]) - qs = urllib.parse.parse_qs(self.rfile.read(content_length).decode()) + def do_GET(self): auth_server = cast(AuthServer, self.server) - if "code" in qs and "id_token" in qs: - self.send_response(307) - # We log out the user directly after authorization, this just clear the browser cache to allow - # another user to authenticate with another email after. This doesn't invalid the access token. - self.send_header("Location", MicrosoftAuthSession.get_logout_url(app_id, exit_redirect_uri)) - auth_server.ms_auth_id_token = qs["id_token"][0] - auth_server.ms_auth_code = qs["code"][0] - self.send_auth_response("Redirecting...") - elif "error" in qs: - self.send_response(400) + if self.path.startswith("/exit"): + self.send_response(200) auth_server.ms_auth_done = True - self.send_auth_response("Error: {} ({}).".format(qs["error_description"][0], qs["error"][0])) + self.send_auth_response("Logged in.") else: self.send_response(404) - self.send_auth_response("Missing parameters.") - else: - self.send_response(404) - self.send_auth_response("Unexpected page.") - - def do_GET(self): - auth_server = cast(AuthServer, self.server) - if self.path.startswith("/exit"): - self.send_response(200) - auth_server.ms_auth_done = True - self.send_auth_response("Logged in.") - else: - self.send_response(404) - self.send_auth_response("Unexpected page.") + self.send_auth_response("Unexpected page.") - ns.out.task("..", "auth.microsoft.opening_browser_and_listening") + ns.out.task("..", "auth.microsoft.opening_browser_and_listening") - with AuthServer() as server: - try: - while not server.ms_auth_done: - server.handle_request() - except KeyboardInterrupt: - pass + with AuthServer() as server: + try: + while not server.ms_auth_done: + server.handle_request() + except KeyboardInterrupt: + pass - if server.ms_auth_code is None or server.ms_auth_id_token is None: - ns.out.finish() - ns.out.task("FAILED", "auth.microsoft.failed_to_authenticate") - ns.out.finish() - return None - else: - ns.out.task("..", "auth.microsoft.processing") - if MicrosoftAuthSession.check_token_id(server.ms_auth_id_token, email, nonce): - return MicrosoftAuthSession.authenticate(ns.auth_database.get_client_id(), app_id, server.ms_auth_code, code_redirect_uri) - else: + if server.ms_auth_code is None or server.ms_auth_id_token is None: ns.out.finish() - ns.out.task("FAILED", "auth.microsoft.incoherent_data") + ns.out.task("FAILED", "auth.microsoft.failed_to_authenticate") ns.out.finish() return None + else: + ns.out.task("..", "auth.microsoft.processing") + if MicrosoftAuthSession.check_token_id(server.ms_auth_id_token, email, nonce): + return MicrosoftAuthSession.authenticate(ns.auth_database.get_client_id(), app_id, server.ms_auth_code, code_redirect_uri) + else: + ns.out.finish() + ns.out.task("FAILED", "auth.microsoft.incoherent_data") + ns.out.finish() + return None class StartWatcher(SimpleWatcher): diff --git a/portablemc/cli/lang.py b/portablemc/cli/lang.py index 92b7ec30..3ab3110d 100644 --- a/portablemc/cli/lang.py +++ b/portablemc/cli/lang.py @@ -51,6 +51,7 @@ def get(key: str, **kwargs) -> str: # Args common langs "args.common.help": "Show this help message and exit.", "args.common.auth_service": "Authentication service type to use for logging in the game.", + "args.common.auth_no_browser": "Prevent the authentication service to open your system's web browser.", # Args search "args.search": "Search for Minecraft versions.", "args.search.kind": "Select the kind of search to operate.", @@ -224,6 +225,7 @@ def get(key: str, **kwargs) -> str: # Auth Microsoft "auth.microsoft": "Authenticating {email} with Microsoft...", "auth.microsoft.no_browser": "Failed to open Microsoft login page, no web browser found on your system.", + "auth.microsoft.no_browser_fallback": "No web browser found on your system, please go to the following url to login:", "auth.microsoft.opening_browser_and_listening": "Opened authentication page in browser...", "auth.microsoft.close_tab_and_return": "Close this tab and return to the launcher.", "auth.microsoft.failed_to_authenticate": "Failed to authenticate.", diff --git a/portablemc/cli/parse.py b/portablemc/cli/parse.py index bb8fcd92..a8c6e2eb 100644 --- a/portablemc/cli/parse.py +++ b/portablemc/cli/parse.py @@ -32,7 +32,11 @@ class SearchNs(RootNs): kind: str input: str -class StartNs(RootNs): +class AuthBaseNs(RootNs): + auth_service: str + auth_no_browser: bool + +class StartNs(AuthBaseNs): dry: bool disable_mp: bool disable_chat: bool @@ -49,21 +53,19 @@ class StartNs(RootNs): exclude_lib: Optional[List[LibrarySpecifierFilter]] include_bin: Optional[List[str]] temp_login: bool - login: str auth_service: str auth_anonymize: bool + login: Optional[str] username: Optional[str] uuid: Optional[str] server: Optional[str] server_port: Optional[int] version: str -class LoginNs(RootNs): - auth_service: str +class LoginNs(AuthBaseNs): email_or_username: str -class LogoutNs(RootNs): - auth_service: str +class LogoutNs(AuthBaseNs): email_or_username: str @@ -74,6 +76,7 @@ def register_common_help(parser: ArgumentParser) -> None: def register_common_auth_service(parser: ArgumentParser) -> None: parser.add_argument("--auth-service", help=_("args.common.auth_service"), default="microsoft", choices=get_auth_services()) + parser.add_argument("--auth-no-browser", help=_("args.common.auth_no_browser"), action="store_true") def register_arguments() -> ArgumentParser: From abb42a651ee4c22edd6bac606d815e1214f35f20 Mon Sep 17 00:00:00 2001 From: Theo Rozier Date: Mon, 15 Jan 2024 14:44:04 +0100 Subject: [PATCH 04/30] Fixed number formatting to keep padding when no unit --- portablemc/cli/util.py | 2 +- test/test_cli_misc.py | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/portablemc/cli/util.py b/portablemc/cli/util.py index f4d9c65e..6b2c6cd9 100644 --- a/portablemc/cli/util.py +++ b/portablemc/cli/util.py @@ -26,7 +26,7 @@ def format_number(n: float) -> str: The string is at most 7 chars unless the size exceed 1 T. """ if n < 1000: - return f"{int(n)}" + return f"{int(n)} " elif n < 1000000: return f"{(int(n / 100) / 10):.1f} k" elif n < 1000000000: diff --git a/test/test_cli_misc.py b/test/test_cli_misc.py index 43128992..8d9cbd13 100644 --- a/test/test_cli_misc.py +++ b/test/test_cli_misc.py @@ -11,8 +11,8 @@ def test_format_number(): from portablemc.cli.util import format_number, format_duration - assert format_number(0) == "0" - assert format_number(999) == "999" + assert format_number(0) == "0 " + assert format_number(999) == "999 " assert format_number(1000) == "1.0 k" assert format_number(999999) == "999.9 k" assert format_number(1000000) == "1.0 M" From 132f6b15fb3c23acd69606845840728bf50208fb Mon Sep 17 00:00:00 2001 From: Theo Rozier Date: Mon, 15 Jan 2024 14:44:21 +0100 Subject: [PATCH 05/30] Improved forge alias resolution (#189) --- portablemc/forge.py | 33 ++++++++++++++++----------------- 1 file changed, 16 insertions(+), 17 deletions(-) diff --git a/portablemc/forge.py b/portablemc/forge.py index a68c89e6..a90fd383 100644 --- a/portablemc/forge.py +++ b/portablemc/forge.py @@ -47,33 +47,32 @@ def _resolve_version(self, watcher: Watcher) -> None: if self._forge_repo == _FORGE_REPO: - # No dash or alias version, resolve against promo version. - alias = self.forge_version.endswith(("-latest", "-recommended")) - if "-" not in self.forge_version or alias: + # If no alias is specified, with add recommended. + if "-" not in self.forge_version: + self.forge_version = f"{self.forge_version}-recommended" + + # Now, if the specified version is an alias, we resolve it. + if self.forge_version.endswith(("-latest", "-recommended")): - # If it's not an alias, create the alias from the game version. - alias_version = self.forge_version if alias else f"{self.forge_version}-recommended" - watcher.handle(ForgeResolveEvent(alias_version, True, _forge_repo=_FORGE_REPO)) + # Split the version, used later. + alias_version, alias = self.forge_version.rsplit("-", maxsplit=1) + watcher.handle(ForgeResolveEvent(self.forge_version, True, _forge_repo=_FORGE_REPO)) # Try to get loader from promo versions. promo_versions = request_promo_versions() - loader_version = promo_versions.get(alias_version) + loader_version = promo_versions.get(self.forge_version) - # Try with "-latest", some version do not have recommended. - if loader_version is None and not alias: - alias_version = f"{self.forge_version}-latest" - watcher.handle(ForgeResolveEvent(alias_version, True, _forge_repo=_FORGE_REPO)) - loader_version = promo_versions.get(alias_version) - - # Remove alias - last_dash = alias_version.rindex("-") - alias_version = alias_version[:last_dash] + # If we can't find the load version, just try to other alias (issue #189). + if loader_version is None: + alias = { "latest": "recommended", "recommended": "latest" }[alias] + self.forge_version = f"{alias_version}-{alias}" + watcher.handle(ForgeResolveEvent(self.forge_version, True, _forge_repo=_FORGE_REPO)) + loader_version = promo_versions.get(self.forge_version) if loader_version is None: raise VersionNotFoundError(f"{self.prefix}-{alias_version}-???") self.forge_version = f"{alias_version}-{loader_version}" - watcher.handle(ForgeResolveEvent(self.forge_version, False, _forge_repo=_FORGE_REPO)) elif self._forge_repo == _NEO_FORGE_REPO: From 5d419dc176aa2c2e83c478e2c78204f38cc21582 Mon Sep 17 00:00:00 2001 From: Theo Rozier Date: Tue, 23 Jan 2024 14:32:11 +0100 Subject: [PATCH 06/30] Deprecated url-related function in the auth API. --- portablemc/auth.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/portablemc/auth.py b/portablemc/auth.py index 080f6f19..6bcaae89 100644 --- a/portablemc/auth.py +++ b/portablemc/auth.py @@ -237,6 +237,7 @@ def refresh(self): @staticmethod def get_authentication_url(app_id: str, redirect_uri: str, email: str, nonce: str): + """deprecated""" return "https://login.live.com/oauth20_authorize.srf?{}".format(url_parse.urlencode({ "client_id": app_id, "redirect_uri": redirect_uri, @@ -249,6 +250,7 @@ def get_authentication_url(app_id: str, redirect_uri: str, email: str, nonce: st @staticmethod def get_logout_url(app_id: str, redirect_uri: str): + """deprecated""" return "https://login.live.com/oauth20_logout.srf?{}".format(url_parse.urlencode({ "client_id": app_id, "redirect_uri": redirect_uri @@ -256,6 +258,7 @@ def get_logout_url(app_id: str, redirect_uri: str): @classmethod def check_token_id(cls, token_id: str, email: str, nonce: str) -> bool: + """deprecated""" id_token_payload = cls.decode_jwt_payload(token_id) return id_token_payload["nonce"] == nonce and id_token_payload["email"].casefold() == email.casefold() From 4977f4566fa6e2152343d037e8fbd17b763ea31c Mon Sep 17 00:00:00 2001 From: Theo Rozier Date: Tue, 23 Jan 2024 14:33:26 +0100 Subject: [PATCH 07/30] Drastically simplified the Microsoft authentication, now using a redirect endpoint that I host publicly and have a small script that help the user. This also works without web browser. #194 --- portablemc/cli/__init__.py | 154 +++++++++++++++++++------------------ portablemc/cli/lang.py | 4 +- 2 files changed, 83 insertions(+), 75 deletions(-) diff --git a/portablemc/cli/__init__.py b/portablemc/cli/__init__.py index 7bfb2ad4..7a7fe073 100644 --- a/portablemc/cli/__init__.py +++ b/portablemc/cli/__init__.py @@ -616,7 +616,7 @@ def prompt_authenticate(ns: AuthBaseNs, email: str, caching: bool, anonymise: bo def prompt_yggdrasil_authenticate(ns: RootNs, email_or_username: str) -> Optional[YggdrasilAuthSession]: ns.out.finish() - ns.out.task(None, "auth.yggdrasil.enter_password") + ns.out.task("..", "auth.yggdrasil.enter_password") password = ns.out.prompt(password=True) if password is None: ns.out.task("FAILED", "cancelled") @@ -634,35 +634,33 @@ def prompt_microsoft_authenticate(ns: AuthBaseNs, email: str) -> Optional[Micros import urllib.parse import webbrowser - server_port = 12782 - app_id = MICROSOFT_AZURE_APP_ID - redirect_auth = f"http://localhost:{server_port}" - code_redirect_uri = f"{redirect_auth}/code" - exit_redirect_uri = f"{redirect_auth}/exit" - nonce = uuid4().hex - - auth_url = MicrosoftAuthSession.get_authentication_url(app_id, code_redirect_uri, email, nonce) - - if ns.auth_no_browser or not webbrowser.open(auth_url): - - ns.out.finish() - ns.out.task("INFO", "auth.microsoft.no_browser_fallback") - ns.out.finish() - ns.out.print(auth_url + "\n") - - return None + app_id = MICROSOFT_AZURE_APP_ID + redirect_uri = "https://www.theorozier.fr/portablemc/auth" + + def gen_auth_url(state: str) -> str: + return "https://login.live.com/oauth20_authorize.srf?{}".format(urllib.parse.urlencode({ + "client_id": app_id, + "redirect_uri": redirect_uri, + "response_type": "code id_token", + "scope": "xboxlive.signin offline_access openid email", + "login_hint": email, + "nonce": nonce, + "state": state, + "prompt": "login", + "response_mode": "fragment" + })) - else: + auth_query = None + + if not ns.auth_no_browser: class AuthServer(HTTPServer): def __init__(self): - super().__init__(("", server_port), RequestHandler) + super().__init__(("127.0.0.1", 0), RequestHandler) self.timeout = 0.5 - self.ms_auth_done = False - self.ms_auth_id_token: Optional[str] = None - self.ms_auth_code: Optional[str] = None + self.ms_auth_query: Optional[str] = None class RequestHandler(BaseHTTPRequestHandler): @@ -674,68 +672,78 @@ def __init__(self, request, client_address: Tuple[str, int], auth_server: AuthSe def log_message(self, _format: str, *args: Any): return - def send_auth_response(self, msg: str): - self.end_headers() - self.wfile.write("{}\n\n{}".format(msg, _('auth.microsoft.close_tab_and_return') if cast(AuthServer, self.server).ms_auth_done else "").encode()) - self.wfile.flush() - - def do_POST(self): - if self.path.startswith("/code") and self.headers.get_content_type() == "application/x-www-form-urlencoded": - content_length = int(self.headers["Content-Length"]) - qs = urllib.parse.parse_qs(self.rfile.read(content_length).decode()) - auth_server = cast(AuthServer, self.server) - if "code" in qs and "id_token" in qs: - self.send_response(307) - # We log out the user directly after authorization, this just clear the browser cache to allow - # another user to authenticate with another email after. This doesn't invalid the access token. - self.send_header("Location", MicrosoftAuthSession.get_logout_url(app_id, exit_redirect_uri)) - auth_server.ms_auth_id_token = qs["id_token"][0] - auth_server.ms_auth_code = qs["code"][0] - self.send_auth_response("Redirecting...") - elif "error" in qs: - self.send_response(400) - auth_server.ms_auth_done = True - self.send_auth_response("Error: {} ({}).".format(qs["error_description"][0], qs["error"][0])) - else: - self.send_response(404) - self.send_auth_response("Missing parameters.") - else: - self.send_response(404) - self.send_auth_response("Unexpected page.") - def do_GET(self): - auth_server = cast(AuthServer, self.server) - if self.path.startswith("/exit"): + parsed = urllib.parse.urlparse(self.path) + if parsed.path in ("", "/"): + cast(AuthServer, self.server).ms_auth_query = parsed.query self.send_response(200) - auth_server.ms_auth_done = True - self.send_auth_response("Logged in.") else: self.send_response(404) - self.send_auth_response("Unexpected page.") + self.send_header("Access-Control-Allow-Origin", "*") + self.end_headers() + self.wfile.flush() + + # We start be creating the authentication server, at this point we don't start it + # but we have allocated a free port. + with AuthServer() as server: - ns.out.task("..", "auth.microsoft.opening_browser_and_listening") + # First try opening the authentication page with the local webbrowser + if webbrowser.open(gen_auth_url(f"port:{server.server_port}")): + # If successfully opened the browser, we actually start the web server. + ns.out.task("..", "auth.microsoft.opening_browser_and_listening") + try: + while server.ms_auth_query is None: + server.handle_request() + except KeyboardInterrupt: + pass + + if server.ms_auth_query is None: + ns.out.finish() + ns.out.task("FAILED", "auth.microsoft.failed_to_authenticate") + ns.out.finish() + return None - with AuthServer() as server: - try: - while not server.ms_auth_done: - server.handle_request() - except KeyboardInterrupt: - pass + auth_query = server.ms_auth_query + + # If here we have code or id token none, it means that no web browser has been opened. + # So we want to print the URL auth URL so the user can try manually. + if auth_query is None: + + ns.out.task("INFO", "auth.microsoft.no_browser_fallback") + ns.out.finish() + ns.out.print(gen_auth_url("") + "\n") - if server.ms_auth_code is None or server.ms_auth_id_token is None: + ns.out.task("..", "auth.microsoft.no_browser_code") + auth_query = ns.out.prompt() + if auth_query is None: ns.out.finish() ns.out.task("FAILED", "auth.microsoft.failed_to_authenticate") ns.out.finish() return None else: - ns.out.task("..", "auth.microsoft.processing") - if MicrosoftAuthSession.check_token_id(server.ms_auth_id_token, email, nonce): - return MicrosoftAuthSession.authenticate(ns.auth_database.get_client_id(), app_id, server.ms_auth_code, code_redirect_uri) - else: - ns.out.finish() - ns.out.task("FAILED", "auth.microsoft.incoherent_data") - ns.out.finish() - return None + auth_query = auth_query.strip() + + qs = urllib.parse.parse_qs(auth_query) + + if "code" in qs and "id_token" in qs: + + ns.out.task("..", "auth.microsoft.processing") + id_token = qs["id_token"][0] + code = qs["code"][0] + + if not MicrosoftAuthSession.check_token_id(id_token, email, nonce): + ns.out.finish() + ns.out.task("FAILED", "auth.microsoft.incoherent_data") + ns.out.finish() + return None + + return MicrosoftAuthSession.authenticate(ns.auth_database.get_client_id(), app_id, code, redirect_uri) + + else: + ns.out.finish() + ns.out.task("FAILED", "auth.microsoft.failed_to_authenticate") + ns.out.finish() + return None class StartWatcher(SimpleWatcher): diff --git a/portablemc/cli/lang.py b/portablemc/cli/lang.py index 3ab3110d..1fbacb42 100644 --- a/portablemc/cli/lang.py +++ b/portablemc/cli/lang.py @@ -224,8 +224,8 @@ def get(key: str, **kwargs) -> str: "auth.yggdrasil.deprecated": "Mojang authentication is deprecated and does not work anymore.", # Auth Microsoft "auth.microsoft": "Authenticating {email} with Microsoft...", - "auth.microsoft.no_browser": "Failed to open Microsoft login page, no web browser found on your system.", - "auth.microsoft.no_browser_fallback": "No web browser found on your system, please go to the following url to login:", + "auth.microsoft.no_browser_fallback": "Authenticating without local browser, please go to the following url to login:", + "auth.microsoft.no_browser_code": "Paste the code: ", "auth.microsoft.opening_browser_and_listening": "Opened authentication page in browser...", "auth.microsoft.close_tab_and_return": "Close this tab and return to the launcher.", "auth.microsoft.failed_to_authenticate": "Failed to authenticate.", From bdcbbbb74c33269ed998a89867406dbf4f2f1b31 Mon Sep 17 00:00:00 2001 From: Theo Rozier Date: Fri, 26 Jan 2024 10:05:39 +0100 Subject: [PATCH 08/30] Version 4.2.0, also added certifi as extra dependency --- poetry.lock | 34 ++++++++++++++++++++++++---------- portablemc/__init__.py | 6 +++--- pyproject.toml | 6 +++++- 3 files changed, 32 insertions(+), 14 deletions(-) diff --git a/poetry.lock b/poetry.lock index 2015dfda..4041f05f 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1,5 +1,16 @@ # This file is automatically @generated by Poetry 1.5.1 and should not be changed by hand. +[[package]] +name = "certifi" +version = "2023.11.17" +description = "Python package for providing Mozilla's CA Bundle." +optional = true +python-versions = ">=3.6" +files = [ + {file = "certifi-2023.11.17-py3-none-any.whl", hash = "sha256:e036ab49d5b79556f99cfc2d9320b34cfbe5be05c5871b51de9329f0603b0474"}, + {file = "certifi-2023.11.17.tar.gz", hash = "sha256:9b469f3a900bf28dc19b8cfbf8019bf47f7fdd1a65a1d4ffb98fc14166beb4d1"}, +] + [[package]] name = "colorama" version = "0.4.6" @@ -85,13 +96,13 @@ toml = ["tomli"] [[package]] name = "exceptiongroup" -version = "1.1.2" +version = "1.2.0" description = "Backport of PEP 654 (exception groups)" optional = false python-versions = ">=3.7" files = [ - {file = "exceptiongroup-1.1.2-py3-none-any.whl", hash = "sha256:e346e69d186172ca7cf029c8c1d16235aa0e04035e5750b4b95039e65204328f"}, - {file = "exceptiongroup-1.1.2.tar.gz", hash = "sha256:12c3e887d6485d16943a309616de20ae5582633e0a2eda17f4e10fd61c1e8af5"}, + {file = "exceptiongroup-1.2.0-py3-none-any.whl", hash = "sha256:4bfd3996ac73b41e9b9628b04e079f193850720ea5945fc96a08633c66912f14"}, + {file = "exceptiongroup-1.2.0.tar.gz", hash = "sha256:91f5c769735f051a4290d52edd0858999b57e5876e9f85937691bd4c9fa3ed68"}, ] [package.extras] @@ -130,13 +141,13 @@ files = [ [[package]] name = "packaging" -version = "23.1" +version = "23.2" description = "Core utilities for Python packages" optional = false python-versions = ">=3.7" files = [ - {file = "packaging-23.1-py3-none-any.whl", hash = "sha256:994793af429502c4ea2ebf6bf664629d07c1a9fe974af92966e4b8d2df7edc61"}, - {file = "packaging-23.1.tar.gz", hash = "sha256:a392980d2b6cffa644431898be54b0045151319d1e7ec34f0cfed48767dd334f"}, + {file = "packaging-23.2-py3-none-any.whl", hash = "sha256:8c491190033a9af7e1d931d0b5dacc2ef47509b34dd0de67ed209b5203fc88c7"}, + {file = "packaging-23.2.tar.gz", hash = "sha256:048fb0e9405036518eaaf48a55953c750c11e1a1b68e0dd1a9d62ed0c092cfc5"}, ] [[package]] @@ -159,13 +170,13 @@ testing = ["pytest", "pytest-benchmark"] [[package]] name = "pytest" -version = "7.4.0" +version = "7.4.4" description = "pytest: simple powerful testing with Python" optional = false python-versions = ">=3.7" files = [ - {file = "pytest-7.4.0-py3-none-any.whl", hash = "sha256:78bf16451a2eb8c7a2ea98e32dc119fd2aa758f1d5d66dbf0a59d69a3969df32"}, - {file = "pytest-7.4.0.tar.gz", hash = "sha256:b4bf8c45bd59934ed84001ad51e11b4ee40d40a1229d2c79f9c592b0a3f6bd8a"}, + {file = "pytest-7.4.4-py3-none-any.whl", hash = "sha256:b090cdf5ed60bf4c45261be03239c2c1c22df034fbffe691abe93cd80cea01d8"}, + {file = "pytest-7.4.4.tar.gz", hash = "sha256:2cf0005922c6ace4a3e2ec8b4080eb0d9753fdc93107415332f50ce9e7994280"}, ] [package.dependencies] @@ -228,7 +239,10 @@ files = [ docs = ["furo", "jaraco.packaging (>=9)", "jaraco.tidelift (>=1.4)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-lint"] testing = ["big-O", "flake8 (<5)", "jaraco.functools", "jaraco.itertools", "more-itertools", "pytest (>=6)", "pytest-black (>=0.3.7)", "pytest-checkdocs (>=2.4)", "pytest-cov", "pytest-enabler (>=1.3)", "pytest-flake8", "pytest-mypy (>=0.9.1)"] +[extras] +certifi = ["certifi"] + [metadata] lock-version = "2.0" python-versions = ">=3.7" -content-hash = "2cab62b3493ef065b4c886da47f6e0bd2e7cf1dc137fb88cc0ed08729be993d5" +content-hash = "2b9eaf63eae0c432aaa00279d39844c4cba258e860a05160493126b25405294c" diff --git a/portablemc/__init__.py b/portablemc/__init__.py index 9d745c4f..7606097e 100644 --- a/portablemc/__init__.py +++ b/portablemc/__init__.py @@ -7,7 +7,7 @@ """ LAUNCHER_NAME = "portablemc" -LAUNCHER_VERSION = "4.1.0" -LAUNCHER_AUTHORS = ["Théo Rozier ", "Github contributors"] -LAUNCHER_COPYRIGHT = "PortableMC Copyright (C) 2021-2023 Théo Rozier" +LAUNCHER_VERSION = "4.2.0" +LAUNCHER_AUTHORS = ["Théo Rozier ", "GitHub Contributors"] +LAUNCHER_COPYRIGHT = "PortableMC Copyright (C) 2021-2024 Théo Rozier" LAUNCHER_URL = "https://github.com/mindstorm38/portablemc" diff --git a/pyproject.toml b/pyproject.toml index 9522ebfb..d6afa563 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "poetry.core.masonry.api" [tool.poetry] name = "portablemc" -version = "4.1.0" +version = "4.2.0" description = "PortableMC is a module that provides both an API for development of your custom launcher and an executable script to run PortableMC CLI." authors = ["Théo Rozier "] license = "GPL-3.0-only" @@ -21,6 +21,7 @@ classifiers = [ [tool.poetry.dependencies] python = ">=3.7" +certifi = { version = "*", optional = true } [tool.poetry.group.test.dependencies] pytest = "*" @@ -29,3 +30,6 @@ coverage = "*" [tool.poetry.scripts] portablemc = "portablemc.cli:main" + +[tool.poetry.extras] +certifi = ["certifi"] From 37b846713161629cf437b837e07b8f6f155c0b01 Mon Sep 17 00:00:00 2001 From: Theo Rozier Date: Fri, 26 Jan 2024 11:07:15 +0100 Subject: [PATCH 09/30] Fixed pipeline to install all extras from poetry --- .github/workflows/test.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index aaeaded2..69ca4426 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -38,7 +38,7 @@ jobs: run: | env\Scripts\Activate.ps1 pip install poetry pytest-github-actions-annotate-failures - poetry install + poetry install --all-extras coverage run -m pytest -v test/ - name: Test on UNIX @@ -46,7 +46,7 @@ jobs: run: | source env/bin/activate pip install poetry pytest-github-actions-annotate-failures - poetry install + poetry install --all-extras coverage run -m pytest -v test/ - name: Coverage & codecov From ce45fd7c98519dd7743812311f39a92315c3664f Mon Sep 17 00:00:00 2001 From: Theo Rozier Date: Fri, 26 Jan 2024 21:35:26 +0100 Subject: [PATCH 10/30] Working on argcomplete support (not one yet, also dropped support for Python 3.7 and trying to support 3.12) --- .github/workflows/test.yml | 2 +- README.md | 2 +- poetry.lock | 195 +++++++++++++++---------------------- portablemc/__main__.py | 2 +- portablemc/cli/__init__.py | 4 - portablemc/cli/lang.py | 17 +--- portablemc/cli/parse.py | 51 +++++++--- pyproject.toml | 7 +- 8 files changed, 122 insertions(+), 158 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 69ca4426..3020daac 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -11,7 +11,7 @@ jobs: fail-fast: true matrix: os: [Ubuntu, macOS, Windows] - python-version: ['3.7', '3.8', '3.9', '3.10', '3.11'] + python-version: ['3.8', '3.9', '3.10', '3.11', '3.12'] include: - os: Ubuntu image: ubuntu-22.04 diff --git a/README.md b/README.md index 9d5f280a..3ed479ca 100644 --- a/README.md +++ b/README.md @@ -7,7 +7,7 @@ It also includes fast installation of common mod loaders such as Fabric, Forge a ![illustration](doc/illustration.png) -*This launcher is tested for Python 3.7, 3.8, 3.9, 3.10, 3.11.* +*This launcher is tested for Python 3.8, 3.9, 3.10, 3.11, 3.12.* ## Table of contents - [Installation](#installation) diff --git a/poetry.lock b/poetry.lock index 4041f05f..21e53ea2 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1,4 +1,18 @@ -# This file is automatically @generated by Poetry 1.5.1 and should not be changed by hand. +# This file is automatically @generated by Poetry 1.7.1 and should not be changed by hand. + +[[package]] +name = "argcomplete" +version = "3.2.2" +description = "Bash tab completion for argparse" +optional = true +python-versions = ">=3.8" +files = [ + {file = "argcomplete-3.2.2-py3-none-any.whl", hash = "sha256:e44f4e7985883ab3e73a103ef0acd27299dbfe2dfed00142c35d4ddd3005901d"}, + {file = "argcomplete-3.2.2.tar.gz", hash = "sha256:f3e49e8ea59b4026ee29548e24488af46e30c9de57d48638e24f54a1ea1000a2"}, +] + +[package.extras] +test = ["coverage", "mypy", "pexpect", "ruff", "wheel"] [[package]] name = "certifi" @@ -24,71 +38,63 @@ files = [ [[package]] name = "coverage" -version = "7.2.7" +version = "7.4.1" description = "Code coverage measurement for Python" optional = false -python-versions = ">=3.7" +python-versions = ">=3.8" files = [ - {file = "coverage-7.2.7-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:d39b5b4f2a66ccae8b7263ac3c8170994b65266797fb96cbbfd3fb5b23921db8"}, - {file = "coverage-7.2.7-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:6d040ef7c9859bb11dfeb056ff5b3872436e3b5e401817d87a31e1750b9ae2fb"}, - {file = "coverage-7.2.7-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ba90a9563ba44a72fda2e85302c3abc71c5589cea608ca16c22b9804262aaeb6"}, - {file = "coverage-7.2.7-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e7d9405291c6928619403db1d10bd07888888ec1abcbd9748fdaa971d7d661b2"}, - {file = "coverage-7.2.7-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:31563e97dae5598556600466ad9beea39fb04e0229e61c12eaa206e0aa202063"}, - {file = "coverage-7.2.7-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:ebba1cd308ef115925421d3e6a586e655ca5a77b5bf41e02eb0e4562a111f2d1"}, - {file = "coverage-7.2.7-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:cb017fd1b2603ef59e374ba2063f593abe0fc45f2ad9abdde5b4d83bd922a353"}, - {file = "coverage-7.2.7-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:d62a5c7dad11015c66fbb9d881bc4caa5b12f16292f857842d9d1871595f4495"}, - {file = "coverage-7.2.7-cp310-cp310-win32.whl", hash = "sha256:ee57190f24fba796e36bb6d3aa8a8783c643d8fa9760c89f7a98ab5455fbf818"}, - {file = "coverage-7.2.7-cp310-cp310-win_amd64.whl", hash = "sha256:f75f7168ab25dd93110c8a8117a22450c19976afbc44234cbf71481094c1b850"}, - {file = "coverage-7.2.7-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:06a9a2be0b5b576c3f18f1a241f0473575c4a26021b52b2a85263a00f034d51f"}, - {file = "coverage-7.2.7-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:5baa06420f837184130752b7c5ea0808762083bf3487b5038d68b012e5937dbe"}, - {file = "coverage-7.2.7-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:fdec9e8cbf13a5bf63290fc6013d216a4c7232efb51548594ca3631a7f13c3a3"}, - {file = "coverage-7.2.7-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:52edc1a60c0d34afa421c9c37078817b2e67a392cab17d97283b64c5833f427f"}, - {file = "coverage-7.2.7-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:63426706118b7f5cf6bb6c895dc215d8a418d5952544042c8a2d9fe87fcf09cb"}, - {file = "coverage-7.2.7-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:afb17f84d56068a7c29f5fa37bfd38d5aba69e3304af08ee94da8ed5b0865833"}, - {file = "coverage-7.2.7-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:48c19d2159d433ccc99e729ceae7d5293fbffa0bdb94952d3579983d1c8c9d97"}, - {file = "coverage-7.2.7-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:0e1f928eaf5469c11e886fe0885ad2bf1ec606434e79842a879277895a50942a"}, - {file = "coverage-7.2.7-cp311-cp311-win32.whl", hash = "sha256:33d6d3ea29d5b3a1a632b3c4e4f4ecae24ef170b0b9ee493883f2df10039959a"}, - {file = "coverage-7.2.7-cp311-cp311-win_amd64.whl", hash = "sha256:5b7540161790b2f28143191f5f8ec02fb132660ff175b7747b95dcb77ac26562"}, - {file = "coverage-7.2.7-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:f2f67fe12b22cd130d34d0ef79206061bfb5eda52feb6ce0dba0644e20a03cf4"}, - {file = "coverage-7.2.7-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a342242fe22407f3c17f4b499276a02b01e80f861f1682ad1d95b04018e0c0d4"}, - {file = "coverage-7.2.7-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:171717c7cb6b453aebac9a2ef603699da237f341b38eebfee9be75d27dc38e01"}, - {file = "coverage-7.2.7-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:49969a9f7ffa086d973d91cec8d2e31080436ef0fb4a359cae927e742abfaaa6"}, - {file = "coverage-7.2.7-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:b46517c02ccd08092f4fa99f24c3b83d8f92f739b4657b0f146246a0ca6a831d"}, - {file = "coverage-7.2.7-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:a3d33a6b3eae87ceaefa91ffdc130b5e8536182cd6dfdbfc1aa56b46ff8c86de"}, - {file = "coverage-7.2.7-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:976b9c42fb2a43ebf304fa7d4a310e5f16cc99992f33eced91ef6f908bd8f33d"}, - {file = "coverage-7.2.7-cp312-cp312-win32.whl", hash = "sha256:8de8bb0e5ad103888d65abef8bca41ab93721647590a3f740100cd65c3b00511"}, - {file = "coverage-7.2.7-cp312-cp312-win_amd64.whl", hash = "sha256:9e31cb64d7de6b6f09702bb27c02d1904b3aebfca610c12772452c4e6c21a0d3"}, - {file = "coverage-7.2.7-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:58c2ccc2f00ecb51253cbe5d8d7122a34590fac9646a960d1430d5b15321d95f"}, - {file = "coverage-7.2.7-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d22656368f0e6189e24722214ed8d66b8022db19d182927b9a248a2a8a2f67eb"}, - {file = "coverage-7.2.7-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a895fcc7b15c3fc72beb43cdcbdf0ddb7d2ebc959edac9cef390b0d14f39f8a9"}, - {file = "coverage-7.2.7-cp37-cp37m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e84606b74eb7de6ff581a7915e2dab7a28a0517fbe1c9239eb227e1354064dcd"}, - {file = "coverage-7.2.7-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:0a5f9e1dbd7fbe30196578ca36f3fba75376fb99888c395c5880b355e2875f8a"}, - {file = "coverage-7.2.7-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:419bfd2caae268623dd469eff96d510a920c90928b60f2073d79f8fe2bbc5959"}, - {file = "coverage-7.2.7-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:2aee274c46590717f38ae5e4650988d1af340fe06167546cc32fe2f58ed05b02"}, - {file = "coverage-7.2.7-cp37-cp37m-win32.whl", hash = "sha256:61b9a528fb348373c433e8966535074b802c7a5d7f23c4f421e6c6e2f1697a6f"}, - {file = "coverage-7.2.7-cp37-cp37m-win_amd64.whl", hash = "sha256:b1c546aca0ca4d028901d825015dc8e4d56aac4b541877690eb76490f1dc8ed0"}, - {file = "coverage-7.2.7-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:54b896376ab563bd38453cecb813c295cf347cf5906e8b41d340b0321a5433e5"}, - {file = "coverage-7.2.7-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:3d376df58cc111dc8e21e3b6e24606b5bb5dee6024f46a5abca99124b2229ef5"}, - {file = "coverage-7.2.7-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5e330fc79bd7207e46c7d7fd2bb4af2963f5f635703925543a70b99574b0fea9"}, - {file = "coverage-7.2.7-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1e9d683426464e4a252bf70c3498756055016f99ddaec3774bf368e76bbe02b6"}, - {file = "coverage-7.2.7-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8d13c64ee2d33eccf7437961b6ea7ad8673e2be040b4f7fd4fd4d4d28d9ccb1e"}, - {file = "coverage-7.2.7-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:b7aa5f8a41217360e600da646004f878250a0d6738bcdc11a0a39928d7dc2050"}, - {file = "coverage-7.2.7-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:8fa03bce9bfbeeef9f3b160a8bed39a221d82308b4152b27d82d8daa7041fee5"}, - {file = "coverage-7.2.7-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:245167dd26180ab4c91d5e1496a30be4cd721a5cf2abf52974f965f10f11419f"}, - {file = "coverage-7.2.7-cp38-cp38-win32.whl", hash = "sha256:d2c2db7fd82e9b72937969bceac4d6ca89660db0a0967614ce2481e81a0b771e"}, - {file = "coverage-7.2.7-cp38-cp38-win_amd64.whl", hash = "sha256:2e07b54284e381531c87f785f613b833569c14ecacdcb85d56b25c4622c16c3c"}, - {file = "coverage-7.2.7-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:537891ae8ce59ef63d0123f7ac9e2ae0fc8b72c7ccbe5296fec45fd68967b6c9"}, - {file = "coverage-7.2.7-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:06fb182e69f33f6cd1d39a6c597294cff3143554b64b9825d1dc69d18cc2fff2"}, - {file = "coverage-7.2.7-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:201e7389591af40950a6480bd9edfa8ed04346ff80002cec1a66cac4549c1ad7"}, - {file = "coverage-7.2.7-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f6951407391b639504e3b3be51b7ba5f3528adbf1a8ac3302b687ecababf929e"}, - {file = "coverage-7.2.7-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6f48351d66575f535669306aa7d6d6f71bc43372473b54a832222803eb956fd1"}, - {file = "coverage-7.2.7-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:b29019c76039dc3c0fd815c41392a044ce555d9bcdd38b0fb60fb4cd8e475ba9"}, - {file = "coverage-7.2.7-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:81c13a1fc7468c40f13420732805a4c38a105d89848b7c10af65a90beff25250"}, - {file = "coverage-7.2.7-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:975d70ab7e3c80a3fe86001d8751f6778905ec723f5b110aed1e450da9d4b7f2"}, - {file = "coverage-7.2.7-cp39-cp39-win32.whl", hash = "sha256:7ee7d9d4822c8acc74a5e26c50604dff824710bc8de424904c0982e25c39c6cb"}, - {file = "coverage-7.2.7-cp39-cp39-win_amd64.whl", hash = "sha256:eb393e5ebc85245347950143969b241d08b52b88a3dc39479822e073a1a8eb27"}, - {file = "coverage-7.2.7-pp37.pp38.pp39-none-any.whl", hash = "sha256:b7b4c971f05e6ae490fef852c218b0e79d4e52f79ef0c8475566584a8fb3e01d"}, - {file = "coverage-7.2.7.tar.gz", hash = "sha256:924d94291ca674905fe9481f12294eb11f2d3d3fd1adb20314ba89e94f44ed59"}, + {file = "coverage-7.4.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:077d366e724f24fc02dbfe9d946534357fda71af9764ff99d73c3c596001bbd7"}, + {file = "coverage-7.4.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:0193657651f5399d433c92f8ae264aff31fc1d066deee4b831549526433f3f61"}, + {file = "coverage-7.4.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d17bbc946f52ca67adf72a5ee783cd7cd3477f8f8796f59b4974a9b59cacc9ee"}, + {file = "coverage-7.4.1-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a3277f5fa7483c927fe3a7b017b39351610265308f5267ac6d4c2b64cc1d8d25"}, + {file = "coverage-7.4.1-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6dceb61d40cbfcf45f51e59933c784a50846dc03211054bd76b421a713dcdf19"}, + {file = "coverage-7.4.1-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:6008adeca04a445ea6ef31b2cbaf1d01d02986047606f7da266629afee982630"}, + {file = "coverage-7.4.1-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:c61f66d93d712f6e03369b6a7769233bfda880b12f417eefdd4f16d1deb2fc4c"}, + {file = "coverage-7.4.1-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:b9bb62fac84d5f2ff523304e59e5c439955fb3b7f44e3d7b2085184db74d733b"}, + {file = "coverage-7.4.1-cp310-cp310-win32.whl", hash = "sha256:f86f368e1c7ce897bf2457b9eb61169a44e2ef797099fb5728482b8d69f3f016"}, + {file = "coverage-7.4.1-cp310-cp310-win_amd64.whl", hash = "sha256:869b5046d41abfea3e381dd143407b0d29b8282a904a19cb908fa24d090cc018"}, + {file = "coverage-7.4.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:b8ffb498a83d7e0305968289441914154fb0ef5d8b3157df02a90c6695978295"}, + {file = "coverage-7.4.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:3cacfaefe6089d477264001f90f55b7881ba615953414999c46cc9713ff93c8c"}, + {file = "coverage-7.4.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5d6850e6e36e332d5511a48a251790ddc545e16e8beaf046c03985c69ccb2676"}, + {file = "coverage-7.4.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:18e961aa13b6d47f758cc5879383d27b5b3f3dcd9ce8cdbfdc2571fe86feb4dd"}, + {file = "coverage-7.4.1-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dfd1e1b9f0898817babf840b77ce9fe655ecbe8b1b327983df485b30df8cc011"}, + {file = "coverage-7.4.1-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:6b00e21f86598b6330f0019b40fb397e705135040dbedc2ca9a93c7441178e74"}, + {file = "coverage-7.4.1-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:536d609c6963c50055bab766d9951b6c394759190d03311f3e9fcf194ca909e1"}, + {file = "coverage-7.4.1-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:7ac8f8eb153724f84885a1374999b7e45734bf93a87d8df1e7ce2146860edef6"}, + {file = "coverage-7.4.1-cp311-cp311-win32.whl", hash = "sha256:f3771b23bb3675a06f5d885c3630b1d01ea6cac9e84a01aaf5508706dba546c5"}, + {file = "coverage-7.4.1-cp311-cp311-win_amd64.whl", hash = "sha256:9d2f9d4cc2a53b38cabc2d6d80f7f9b7e3da26b2f53d48f05876fef7956b6968"}, + {file = "coverage-7.4.1-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:f68ef3660677e6624c8cace943e4765545f8191313a07288a53d3da188bd8581"}, + {file = "coverage-7.4.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:23b27b8a698e749b61809fb637eb98ebf0e505710ec46a8aa6f1be7dc0dc43a6"}, + {file = "coverage-7.4.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3e3424c554391dc9ef4a92ad28665756566a28fecf47308f91841f6c49288e66"}, + {file = "coverage-7.4.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e0860a348bf7004c812c8368d1fc7f77fe8e4c095d661a579196a9533778e156"}, + {file = "coverage-7.4.1-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fe558371c1bdf3b8fa03e097c523fb9645b8730399c14fe7721ee9c9e2a545d3"}, + {file = "coverage-7.4.1-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:3468cc8720402af37b6c6e7e2a9cdb9f6c16c728638a2ebc768ba1ef6f26c3a1"}, + {file = "coverage-7.4.1-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:02f2edb575d62172aa28fe00efe821ae31f25dc3d589055b3fb64d51e52e4ab1"}, + {file = "coverage-7.4.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:ca6e61dc52f601d1d224526360cdeab0d0712ec104a2ce6cc5ccef6ed9a233bc"}, + {file = "coverage-7.4.1-cp312-cp312-win32.whl", hash = "sha256:ca7b26a5e456a843b9b6683eada193fc1f65c761b3a473941efe5a291f604c74"}, + {file = "coverage-7.4.1-cp312-cp312-win_amd64.whl", hash = "sha256:85ccc5fa54c2ed64bd91ed3b4a627b9cce04646a659512a051fa82a92c04a448"}, + {file = "coverage-7.4.1-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:8bdb0285a0202888d19ec6b6d23d5990410decb932b709f2b0dfe216d031d218"}, + {file = "coverage-7.4.1-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:918440dea04521f499721c039863ef95433314b1db00ff826a02580c1f503e45"}, + {file = "coverage-7.4.1-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:379d4c7abad5afbe9d88cc31ea8ca262296480a86af945b08214eb1a556a3e4d"}, + {file = "coverage-7.4.1-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b094116f0b6155e36a304ff912f89bbb5067157aff5f94060ff20bbabdc8da06"}, + {file = "coverage-7.4.1-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f2f5968608b1fe2a1d00d01ad1017ee27efd99b3437e08b83ded9b7af3f6f766"}, + {file = "coverage-7.4.1-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:10e88e7f41e6197ea0429ae18f21ff521d4f4490aa33048f6c6f94c6045a6a75"}, + {file = "coverage-7.4.1-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:a4a3907011d39dbc3e37bdc5df0a8c93853c369039b59efa33a7b6669de04c60"}, + {file = "coverage-7.4.1-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:6d224f0c4c9c98290a6990259073f496fcec1b5cc613eecbd22786d398ded3ad"}, + {file = "coverage-7.4.1-cp38-cp38-win32.whl", hash = "sha256:23f5881362dcb0e1a92b84b3c2809bdc90db892332daab81ad8f642d8ed55042"}, + {file = "coverage-7.4.1-cp38-cp38-win_amd64.whl", hash = "sha256:a07f61fc452c43cd5328b392e52555f7d1952400a1ad09086c4a8addccbd138d"}, + {file = "coverage-7.4.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:8e738a492b6221f8dcf281b67129510835461132b03024830ac0e554311a5c54"}, + {file = "coverage-7.4.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:46342fed0fff72efcda77040b14728049200cbba1279e0bf1188f1f2078c1d70"}, + {file = "coverage-7.4.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9641e21670c68c7e57d2053ddf6c443e4f0a6e18e547e86af3fad0795414a628"}, + {file = "coverage-7.4.1-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:aeb2c2688ed93b027eb0d26aa188ada34acb22dceea256d76390eea135083950"}, + {file = "coverage-7.4.1-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d12c923757de24e4e2110cf8832d83a886a4cf215c6e61ed506006872b43a6d1"}, + {file = "coverage-7.4.1-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:0491275c3b9971cdbd28a4595c2cb5838f08036bca31765bad5e17edf900b2c7"}, + {file = "coverage-7.4.1-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:8dfc5e195bbef80aabd81596ef52a1277ee7143fe419efc3c4d8ba2754671756"}, + {file = "coverage-7.4.1-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:1a78b656a4d12b0490ca72651fe4d9f5e07e3c6461063a9b6265ee45eb2bdd35"}, + {file = "coverage-7.4.1-cp39-cp39-win32.whl", hash = "sha256:f90515974b39f4dea2f27c0959688621b46d96d5a626cf9c53dbc653a895c05c"}, + {file = "coverage-7.4.1-cp39-cp39-win_amd64.whl", hash = "sha256:64e723ca82a84053dd7bfcc986bdb34af8d9da83c521c19d6b472bc6880e191a"}, + {file = "coverage-7.4.1-pp38.pp39.pp310-none-any.whl", hash = "sha256:32a8d985462e37cfdab611a6f95b09d7c091d07668fdc26e47a725ee575fe166"}, + {file = "coverage-7.4.1.tar.gz", hash = "sha256:1ed4b95480952b1a26d863e546fa5094564aa0065e1e5f0d4d0041f293251d04"}, ] [package.extras] @@ -108,26 +114,6 @@ files = [ [package.extras] test = ["pytest (>=6)"] -[[package]] -name = "importlib-metadata" -version = "6.7.0" -description = "Read metadata from Python packages" -optional = false -python-versions = ">=3.7" -files = [ - {file = "importlib_metadata-6.7.0-py3-none-any.whl", hash = "sha256:cb52082e659e97afc5dac71e79de97d8681de3aa07ff18578330904a9d18e5b5"}, - {file = "importlib_metadata-6.7.0.tar.gz", hash = "sha256:1aaf550d4f73e5d6783e7acb77aec43d49da8017410afae93822cc9cca98c4d4"}, -] - -[package.dependencies] -typing-extensions = {version = ">=3.6.4", markers = "python_version < \"3.8\""} -zipp = ">=0.5" - -[package.extras] -docs = ["furo", "jaraco.packaging (>=9)", "jaraco.tidelift (>=1.4)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-lint"] -perf = ["ipython"] -testing = ["flufl.flake8", "importlib-resources (>=1.3)", "packaging", "pyfakefs", "pytest (>=6)", "pytest-black (>=0.3.7)", "pytest-checkdocs (>=2.4)", "pytest-cov", "pytest-enabler (>=1.3)", "pytest-mypy (>=0.9.1)", "pytest-perf (>=0.9.2)", "pytest-ruff"] - [[package]] name = "iniconfig" version = "2.0.0" @@ -152,18 +138,15 @@ files = [ [[package]] name = "pluggy" -version = "1.2.0" +version = "1.4.0" description = "plugin and hook calling mechanisms for python" optional = false -python-versions = ">=3.7" +python-versions = ">=3.8" files = [ - {file = "pluggy-1.2.0-py3-none-any.whl", hash = "sha256:c2fd55a7d7a3863cba1a013e4e2414658b1d07b6bc57b3919e0c63c9abb99849"}, - {file = "pluggy-1.2.0.tar.gz", hash = "sha256:d12f0c4b579b15f5e054301bb226ee85eeeba08ffec228092f8defbaa3a4c4b3"}, + {file = "pluggy-1.4.0-py3-none-any.whl", hash = "sha256:7db9f7b503d67d1c5b95f59773ebb58a8c1c288129a88665838012cfb07b8981"}, + {file = "pluggy-1.4.0.tar.gz", hash = "sha256:8c85c2876142a764e5b7548e7d9a0e0ddb46f5185161049a79b7e974454223be"}, ] -[package.dependencies] -importlib-metadata = {version = ">=0.12", markers = "python_version < \"3.8\""} - [package.extras] dev = ["pre-commit", "tox"] testing = ["pytest", "pytest-benchmark"] @@ -182,7 +165,6 @@ files = [ [package.dependencies] colorama = {version = "*", markers = "sys_platform == \"win32\""} exceptiongroup = {version = ">=1.0.0rc8", markers = "python_version < \"3.11\""} -importlib-metadata = {version = ">=0.12", markers = "python_version < \"3.8\""} iniconfig = "*" packaging = "*" pluggy = ">=0.12,<2.0" @@ -213,36 +195,11 @@ files = [ {file = "tomli-2.0.1.tar.gz", hash = "sha256:de526c12914f0c550d15924c62d72abc48d6fe7364aa87328337a31007fe8a4f"}, ] -[[package]] -name = "typing-extensions" -version = "4.7.1" -description = "Backported and Experimental Type Hints for Python 3.7+" -optional = false -python-versions = ">=3.7" -files = [ - {file = "typing_extensions-4.7.1-py3-none-any.whl", hash = "sha256:440d5dd3af93b060174bf433bccd69b0babc3b15b1a8dca43789fd7f61514b36"}, - {file = "typing_extensions-4.7.1.tar.gz", hash = "sha256:b75ddc264f0ba5615db7ba217daeb99701ad295353c45f9e95963337ceeeffb2"}, -] - -[[package]] -name = "zipp" -version = "3.15.0" -description = "Backport of pathlib-compatible object wrapper for zip files" -optional = false -python-versions = ">=3.7" -files = [ - {file = "zipp-3.15.0-py3-none-any.whl", hash = "sha256:48904fc76a60e542af151aded95726c1a5c34ed43ab4134b597665c86d7ad556"}, - {file = "zipp-3.15.0.tar.gz", hash = "sha256:112929ad649da941c23de50f356a2b5570c954b65150642bccdd66bf194d224b"}, -] - -[package.extras] -docs = ["furo", "jaraco.packaging (>=9)", "jaraco.tidelift (>=1.4)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-lint"] -testing = ["big-O", "flake8 (<5)", "jaraco.functools", "jaraco.itertools", "more-itertools", "pytest (>=6)", "pytest-black (>=0.3.7)", "pytest-checkdocs (>=2.4)", "pytest-cov", "pytest-enabler (>=1.3)", "pytest-flake8", "pytest-mypy (>=0.9.1)"] - [extras] +argcomplete = ["argcomplete"] certifi = ["certifi"] [metadata] lock-version = "2.0" -python-versions = ">=3.7" -content-hash = "2b9eaf63eae0c432aaa00279d39844c4cba258e860a05160493126b25405294c" +python-versions = ">=3.8" +content-hash = "8f49016ddc6beb05b95499780658fb3084a294f1c411f0ff5c9b8c36ec32b1a6" diff --git a/portablemc/__main__.py b/portablemc/__main__.py index a2cd07ca..a67e6795 100644 --- a/portablemc/__main__.py +++ b/portablemc/__main__.py @@ -2,7 +2,7 @@ # encoding: utf-8 -# Copyright (C) 2021-2023 Théo Rozier +# Copyright (C) 2021-2024 Théo Rozier # # This program is free software: you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by diff --git a/portablemc/cli/__init__.py b/portablemc/cli/__init__.py index 7a7fe073..3f32535f 100644 --- a/portablemc/cli/__init__.py +++ b/portablemc/cli/__init__.py @@ -125,10 +125,6 @@ def get_command_handlers() -> CommandTree: "auth": cmd_show_auth, "lang": cmd_show_lang, }, - # "addon": { - # "list": cmd_addon_list, - # "show": cmd_addon_show - # } } diff --git a/portablemc/cli/lang.py b/portablemc/cli/lang.py index 1fbacb42..aac66a7f 100644 --- a/portablemc/cli/lang.py +++ b/portablemc/cli/lang.py @@ -33,9 +33,6 @@ def get(key: str, **kwargs) -> str: lang = { - # Addons - # "addon.import_error": "The addon '{addon}' has failed to build because some packages is missing:", - # "addon.unknown_error": "The addon '{addon}' has failed to build for unknown reason:", # Args root "args": "A fast, reliable and cross-platform command-line Minecraft launcher and API " "for developers. This launcher is compatible with the official Minecraft " @@ -55,6 +52,7 @@ def get(key: str, **kwargs) -> str: # Args search "args.search": "Search for Minecraft versions.", "args.search.kind": "Select the kind of search to operate.", + "args.search.input": "Search input.", # Args start "args.start": "Start a Minecraft version.", "args.start.version": "Version identifier (default to release): {formats}.", @@ -108,10 +106,6 @@ def get(key: str, **kwargs) -> str: "args.show.about": "Display authors, version and license of PortableMC.", "args.show.auth": "Debug the authentication database and supported services.", "args.show.lang": "Debug the language mappings used for messages translation.", - # Args addon - # "args.addon": "Addons management subcommands.", - # "args.addon.list": "List addons.", - # "args.addon.show": "Show an addon details.", # Common "echo": "{echo}", "cancelled": "Cancelled.", @@ -140,15 +134,6 @@ def get(key: str, **kwargs) -> str: "logout.microsoft.pending": "Logging out {email} from Microsoft...", "logout.success": "Logged out {email}", "logout.unknown_session": "No session for {email}", - # Command addon list - # "addon.list.id": "ID ({count})", - # "addon.list.version": "Version", - # "addon.list.authors": "Authors", - # Command addon show - # "addon.show.not_found": "Addon '{addon}' not found.", - # "addon.show.version": "Version: {version}", - # "addon.show.authors": "Authors: {authors}", - # "addon.show.description": "Description: {description}", # Command start "start.version.invalid_id": "Invalid version id, expected: {expected}", "start.version.invalid_id_unknown_kind": "Invalid version id, unknown kind: {kind}.", diff --git a/portablemc/cli/parse.py b/portablemc/cli/parse.py index a8c6e2eb..09a7485d 100644 --- a/portablemc/cli/parse.py +++ b/portablemc/cli/parse.py @@ -11,6 +11,10 @@ from typing import Optional, Type, Tuple, List +try: + import argcomplete +except ImportError: + argcomplete = None # No auto completion support # The following classes are only used for type checking and represent a typed namespace # as produced by the arguments registered to the argument parser. @@ -80,14 +84,23 @@ def register_common_auth_service(parser: ArgumentParser) -> None: def register_arguments() -> ArgumentParser: + parser = ArgumentParser(allow_abbrev=False, prog="portablemc", description=_("args"), add_help=False) register_common_help(parser) - parser.add_argument("--main-dir", help=_("args.main_dir"), type=Path) - parser.add_argument("--work-dir", help=_("args.work_dir"), type=Path) + main_dir = parser.add_argument("--main-dir", help=_("args.main_dir"), type=Path) + work_dir = parser.add_argument("--work-dir", help=_("args.work_dir"), type=Path) parser.add_argument("--timeout", help=_("args.timeout"), type=float) parser.add_argument("--output", help=_("args.output"), dest="out_kind", choices=get_outputs(), default="human-color" if sys.stdout.isatty() else "human") parser.add_argument("-v", dest="verbose", help=_("args.verbose"), action="count", default=0) register_subcommands(parser.add_subparsers(title="subcommands", dest="subcommand")) + set_directories_completer(main_dir) + set_directories_completer(work_dir) + + # If the "argcomplete" extra dependency is installed, we generate an autocompletion. + # Note: argcomplete is not type-hinted. + if argcomplete is not None: + argcomplete.autocomplete(parser, default_completer=None) # type: ignore + return parser @@ -97,13 +110,13 @@ def register_subcommands(subparsers) -> None: register_login_arguments(subparsers.add_parser("login", help=_("args.login"), add_help=False)) register_logout_arguments(subparsers.add_parser("logout", help=_("args.logout"), add_help=False)) register_show_arguments(subparsers.add_parser("show", help=_("args.show"), add_help=False)) - # register_addon_arguments(subparsers.add_parser("addon", help=_("args.addon"))) def register_search_arguments(parser: ArgumentParser) -> None: register_common_help(parser) parser.add_argument("-k", "--kind", help=_("args.search.kind"), default="mojang", choices=get_search_kinds()) - parser.add_argument("input", nargs="?") + input_arg = parser.add_argument("input", nargs="?", help=_("args.search.input")) + set_choices_completer(input_arg, ("release", "snapshot")) def register_start_arguments(parser: ArgumentParser) -> None: @@ -113,7 +126,7 @@ def register_start_arguments(parser: ArgumentParser) -> None: parser.add_argument("--disable-chat", help=_("args.start.disable_chat"), action="store_true") parser.add_argument("--demo", help=_("args.start.demo"), action="store_true") parser.add_argument("--resolution", help=_("args.start.resolution"), type=resolution_from_str) - parser.add_argument("--jvm", help=_("args.start.jvm")) + jvm_arg = parser.add_argument("--jvm", help=_("args.start.jvm")) parser.add_argument("--jvm-args", help=_("args.start.jvm_args"), metavar="ARGS") parser.add_argument("--no-fix", help=_("args.start.no_fix"), action="store_true") parser.add_argument("--fabric-prefix", help=_("args.start.fabric_prefix"), default="fabric", metavar="PREFIX") @@ -122,7 +135,7 @@ def register_start_arguments(parser: ArgumentParser) -> None: parser.add_argument("--neoforge-prefix", help=_("args.start.neoforge_prefix"), default="neoforge", metavar="PREFIX") parser.add_argument("--lwjgl", help=_("args.start.lwjgl")) parser.add_argument("--exclude-lib", help=_("args.start.exclude_lib"), action="append", metavar="SPEC", type=LibrarySpecifierFilter.from_str) - parser.add_argument("--include-bin", help=_("args.start.include_bin"), action="append", metavar="PATH") + include_bin_arg = parser.add_argument("--include-bin", help=_("args.start.include_bin"), action="append", metavar="PATH") parser.add_argument("--auth-anonymize", help=_("args.start.auth_anonymize"), action="store_true") register_common_auth_service(parser) parser.add_argument("-t", "--temp-login", help=_("args.start.temp_login"), action="store_true") @@ -131,7 +144,10 @@ def register_start_arguments(parser: ArgumentParser) -> None: parser.add_argument("-i", "--uuid", help=_("args.start.uuid")) parser.add_argument("-s", "--server", help=_("args.start.server")) parser.add_argument("-p", "--server-port", type=int, help=_("args.start.server_port"), metavar="PORT") - parser.add_argument("version", nargs="?", default="release", help=_("args.start.version", formats=", ".join(map(lambda s: _(f"args.start.version.{s}"), ("standard", "fabric", "quilt", "forge", "neoforge"))))) + version_arg = parser.add_argument("version", nargs="?", default="release", help=_("args.start.version", formats=", ".join(map(lambda s: _(f"args.start.version.{s}"), ("standard", "fabric", "quilt", "forge", "neoforge"))))) + set_files_completer(jvm_arg) + set_files_completer(include_bin_arg) + set_choices_completer(version_arg, ("release", "snapshot", "fabric:", "quilt:", "forge:", "neoforge:")) def register_login_arguments(parser: ArgumentParser) -> None: @@ -155,14 +171,6 @@ def register_show_arguments(parser: ArgumentParser) -> None: subparsers.add_parser("lang", help=_("args.show.lang"), add_help=False) -# def register_addon_arguments(parser: ArgumentParser): -# subparsers = parser.add_subparsers(title="subcommands", dest="addon_subcommand") -# subparsers.required = True -# subparsers.add_parser("list", help=_("args.addon.list")) -# show_parser = subparsers.add_parser("show", help=_("args.addon.show")) -# show_parser.add_argument("addon_id") - - def new_help_formatter_class(max_help_position: int) -> Type[HelpFormatter]: class CustomHelpFormatter(HelpFormatter): @@ -190,3 +198,16 @@ def resolution_from_str(s: str) -> Tuple[int, int]: return (int(parts[0]), int(parts[1])) else: raise ArgumentTypeError(_("args.start.resolution.invalid", given=s)) + + +def set_choices_completer(action, choices): + if argcomplete is not None: + action.completer = argcomplete.completers.ChoicesCompleter(choices) + +def set_files_completer(action): + if argcomplete is not None: + action.completer = argcomplete.completers.FilesCompleter() + +def set_directories_completer(action): + if argcomplete is not None: + action.completer = argcomplete.completers.DirectoriesCompleter() diff --git a/pyproject.toml b/pyproject.toml index d6afa563..1467c2bd 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -20,8 +20,11 @@ classifiers = [ ] [tool.poetry.dependencies] -python = ">=3.7" +# We want to keep the Minimum Supported Python Version to the oldest non-end-of-life +# version. See https://devguide.python.org/versions/ +python = ">=3.8" certifi = { version = "*", optional = true } +argcomplete = { version = "^3.1.5", optional = true } [tool.poetry.group.test.dependencies] pytest = "*" @@ -33,3 +36,5 @@ portablemc = "portablemc.cli:main" [tool.poetry.extras] certifi = ["certifi"] +argcomplete = ["argcomplete"] + From 489549807e636f02ec21c263a66100ba19753378 Mon Sep 17 00:00:00 2001 From: Theo Rozier Date: Sun, 28 Jan 2024 14:47:58 +0100 Subject: [PATCH 11/30] Dropped argcomplete and working on a standalone arg completion generation that better use Zsh features (#111). --- poetry.lock | 27 ++------ portablemc/cli/__init__.py | 18 ++++- portablemc/cli/complete.py | 132 +++++++++++++++++++++++++++++++++++++ portablemc/cli/lang.py | 24 ++++++- portablemc/cli/parse.py | 128 ++++++++++++++++++++++------------- pyproject.toml | 3 - 6 files changed, 258 insertions(+), 74 deletions(-) create mode 100644 portablemc/cli/complete.py diff --git a/poetry.lock b/poetry.lock index 21e53ea2..fae0eaac 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1,19 +1,5 @@ # This file is automatically @generated by Poetry 1.7.1 and should not be changed by hand. -[[package]] -name = "argcomplete" -version = "3.2.2" -description = "Bash tab completion for argparse" -optional = true -python-versions = ">=3.8" -files = [ - {file = "argcomplete-3.2.2-py3-none-any.whl", hash = "sha256:e44f4e7985883ab3e73a103ef0acd27299dbfe2dfed00142c35d4ddd3005901d"}, - {file = "argcomplete-3.2.2.tar.gz", hash = "sha256:f3e49e8ea59b4026ee29548e24488af46e30c9de57d48638e24f54a1ea1000a2"}, -] - -[package.extras] -test = ["coverage", "mypy", "pexpect", "ruff", "wheel"] - [[package]] name = "certifi" version = "2023.11.17" @@ -153,13 +139,13 @@ testing = ["pytest", "pytest-benchmark"] [[package]] name = "pytest" -version = "7.4.4" +version = "8.0.0" description = "pytest: simple powerful testing with Python" optional = false -python-versions = ">=3.7" +python-versions = ">=3.8" files = [ - {file = "pytest-7.4.4-py3-none-any.whl", hash = "sha256:b090cdf5ed60bf4c45261be03239c2c1c22df034fbffe691abe93cd80cea01d8"}, - {file = "pytest-7.4.4.tar.gz", hash = "sha256:2cf0005922c6ace4a3e2ec8b4080eb0d9753fdc93107415332f50ce9e7994280"}, + {file = "pytest-8.0.0-py3-none-any.whl", hash = "sha256:50fb9cbe836c3f20f0dfa99c565201fb75dc54c8d76373cd1bde06b06657bdb6"}, + {file = "pytest-8.0.0.tar.gz", hash = "sha256:249b1b0864530ba251b7438274c4d251c58d868edaaec8762893ad4a0d71c36c"}, ] [package.dependencies] @@ -167,7 +153,7 @@ colorama = {version = "*", markers = "sys_platform == \"win32\""} exceptiongroup = {version = ">=1.0.0rc8", markers = "python_version < \"3.11\""} iniconfig = "*" packaging = "*" -pluggy = ">=0.12,<2.0" +pluggy = ">=1.3.0,<2.0" tomli = {version = ">=1.0.0", markers = "python_version < \"3.11\""} [package.extras] @@ -196,10 +182,9 @@ files = [ ] [extras] -argcomplete = ["argcomplete"] certifi = ["certifi"] [metadata] lock-version = "2.0" python-versions = ">=3.8" -content-hash = "8f49016ddc6beb05b95499780658fb3084a294f1c411f0ff5c9b8c36ec32b1a6" +content-hash = "35b4199432e6a29f6e16993b314c0bdcbed39e3496e95d8f9f416beffe00f12c" diff --git a/portablemc/cli/__init__.py b/portablemc/cli/__init__.py index 3f32535f..1bfa09ee 100644 --- a/portablemc/cli/__init__.py +++ b/portablemc/cli/__init__.py @@ -12,7 +12,9 @@ import socket import sys -from .parse import register_arguments, RootNs, SearchNs, StartNs, LoginNs, LogoutNs, AuthBaseNs +from .parse import register_arguments, RootNs, SearchNs, StartNs, LoginNs, LogoutNs, AuthBaseNs, ShowCompletionNs +from .complete import gen_zsh_completion + from .util import format_locale_date, format_time, format_number, anonymize_email from .output import Output, HumanOutput, MachineOutput, OutputTable from .lang import get as _, lang @@ -70,6 +72,7 @@ def main(args: Optional[List[str]] = None): ns: RootNs = cast(RootNs, parser.parse_args(args or sys.argv[1:])) # Setup common objects in the namespace. + ns.parser = parser ns.out = get_output(ns.out_kind) ns.context = Context(ns.main_dir, ns.work_dir) ns.version_manifest = VersionManifest(ns.context.work_dir / MANIFEST_CACHE_FILE_NAME) @@ -124,6 +127,7 @@ def get_command_handlers() -> CommandTree: "about": cmd_show_about, "auth": cmd_show_auth, "lang": cmd_show_lang, + "completion": cmd_show_completion, }, } @@ -310,7 +314,7 @@ def cmd_start(ns: StartNs): version.disable_chat = ns.disable_chat version.demo = ns.demo version.resolution = ns.resolution - version.jvm_path = None if ns.jvm is None else Path(ns.jvm) + version.jvm_path = ns.jvm if ns.server is not None: version.set_quick_play_multiplayer(ns.server, ns.server_port or 25565) @@ -538,6 +542,16 @@ def cmd_show_lang(ns: RootNs): table.print() +def cmd_show_completion(ns: ShowCompletionNs): + + if ns.shell == "zsh": + content = gen_zsh_completion(ns.parser) + else: + raise RuntimeError + + print(content, end="") + + def prompt_authenticate(ns: AuthBaseNs, email: str, caching: bool, anonymise: bool = False) -> Optional[AuthSession]: """Prompt the user to login using the given email (or legacy username) for specific service (Microsoft or Yggdrasil) and return the :class:`AuthSession` if successful, diff --git a/portablemc/cli/complete.py b/portablemc/cli/complete.py new file mode 100644 index 00000000..c6fed4c3 --- /dev/null +++ b/portablemc/cli/complete.py @@ -0,0 +1,132 @@ +from io import StringIO +from argparse import ArgumentParser, \ + _CountAction, _StoreAction, _SubParsersAction, \ + _StoreConstAction, _HelpAction, _AppendConstAction + +from .lang import get as _ +from .parse import type_path, type_path_dir, \ + type_email_or_username, type_host, get_completions + +from typing import Dict, Tuple, cast + + +def escape_zsh(s: str, *, space = False) -> str: + s = s.replace("'", "''").replace("[", "\\[").replace("]", "\\]").replace(":", "\\:") + if space: + s = s.replace(" ", "\\ ") + return s + + +def gen_zsh_completion(parser: ArgumentParser) -> str: + buffer = StringIO() + gen_zsh_parser_completion(parser, buffer, "_complete_portablemc") + buffer.write("compdef _complete_portablemc portablemc\n") + return buffer.getvalue() + + +def gen_zsh_parser_completion(parser: ArgumentParser, buffer: StringIO, function: str): + + commands: Dict[str, Tuple[str, ArgumentParser]] = {} + completions: Dict[str, Dict[str, str]] = {} + + buffer.write(function) + buffer.write("() {\n") + buffer.write(" local curcontext=$curcontext state line\n") + buffer.write(" integer ret=1\n") + + buffer.write(" _arguments -s -C \\\n") + + for action in parser._actions: + + zsh_description = escape_zsh(action.help or "") + zsh_repeat = "" + zsh_action = ": :" + + # Depending on the action type there are some specific things we can do. + if isinstance(action, _CountAction): + zsh_repeat = "\\*" + zsh_action = "" + elif isinstance(action, _StoreAction): + + action_completions = get_completions(action) + if action.choices is not None: + for choice in action.choices: + if choice not in action_completions: + action_completions[choice] = "" + + if action.type == type_path: + zsh_action = ": :_files" + elif action.type == type_path_dir: + zsh_action = ": :_files -/" + elif action.type == type_email_or_username: + zsh_action = ": :_email_addresses -c" + elif action.type == type_host: + zsh_action = ": :_hosts" + elif len(action_completions): + zsh_action = f": :->action_{action.dest}" + completions[f"action_{action.dest}"] = action_completions + + elif isinstance(action, (_HelpAction, _StoreConstAction, _AppendConstAction)): + zsh_action = "" + elif isinstance(action, _SubParsersAction): + parsers_choices = cast(Dict[str, ArgumentParser], action.choices) + for sub_action in action._get_subactions(): + commands[sub_action.dest] = (sub_action.help or "", parsers_choices[sub_action.dest]) + continue + + # If the argument is positional. + if not len(action.option_strings): + buffer.write(f" '{zsh_action}' \\\n") + continue + + # If the argument is an option. + if len(action.option_strings) > 1: + zsh_names = f"{{{','.join(action.option_strings)}}}" + else: + zsh_names = action.option_strings[0] + buffer.write(f" {zsh_repeat}{zsh_names}'[{zsh_description}]{zsh_action}' \\\n") + + if len(commands): + buffer.write(" ': :->command' \\\n") + buffer.write(" '*:: :->option' \\\n") + + buffer.write(" && ret=0\n") + + if len(commands) or len(completions): + + buffer.write(" case $state in\n") + + if len(commands): + buffer.write(" command)\n") + buffer.write(" local -a commands=(\n") + for name, (description, parser) in commands.items(): + buffer.write(f" '{name}:{escape_zsh(description)}'\n") + buffer.write(" )\n") + buffer.write(" _describe -t commands command commands && ret=0\n") + buffer.write(" ;;\n") + + buffer.write(" option)\n") + buffer.write(" case $line[1] in\n") + for name, (description, parser) in commands.items(): + buffer.write(f" {name}) {function}_{name} ;;\n") + buffer.write(" esac\n") + buffer.write(" ;;\n") + + for state, action_completions in completions.items(): + buffer.write(f" {state})\n") + buffer.write(" local -a completions=(\n") + for name, description in action_completions.items(): + if len(description): + buffer.write(f" '{escape_zsh(name)}:{escape_zsh(description)}'\n") + else: + buffer.write(f" '{escape_zsh(name)}'\n") + buffer.write(" )\n") + buffer.write(" _describe -t values value completions && ret=0\n") + buffer.write(" ;;\n") + + buffer.write(" esac\n") + + buffer.write("}\n\n") + + for name, (description, parser) in commands.items(): + gen_zsh_parser_completion(parser, buffer, f"{function}_{name}") diff --git a/portablemc/cli/lang.py b/portablemc/cli/lang.py index aac66a7f..0522ff78 100644 --- a/portablemc/cli/lang.py +++ b/portablemc/cli/lang.py @@ -44,15 +44,27 @@ def get(key: str, **kwargs) -> str: "runtime binaries and authentication.", "args.timeout": "Set a global timeout (in decimal seconds) for network requests.", "args.output": "Set the output format of the launcher, defaults to human-color, human if not a TTY.", + "args.output.comp.human-color": "Human readable output with color.", + "args.output.comp.human": "Human readable output.", + "args.output.comp.machine": "Machine readable output.", "args.verbose": "Enable verbose output. The more -v argument you put, the more verbose the launcher will be, depending on subcommands' support (usually -v, -vv, -vvv).", # Args common langs "args.common.help": "Show this help message and exit.", "args.common.auth_service": "Authentication service type to use for logging in the game.", + "args.common.auth_service.comp.microsoft": "Microsoft authentication (default).", + "args.common.auth_service.comp.yggdrasil": "Mojang authentication (deprecated).", "args.common.auth_no_browser": "Prevent the authentication service to open your system's web browser.", # Args search "args.search": "Search for Minecraft versions.", "args.search.kind": "Select the kind of search to operate.", + "args.search.kind.comp.mojang": "Search for official Mojang versions (default).", + "args.search.kind.comp.local": "Search for locally installed versions.", + "args.search.kind.comp.forge": "Search for Forge versions.", + "args.search.kind.comp.fabric": "Search for Fabric versions.", + "args.search.kind.comp.quilt": "Search for Quilt versions.", "args.search.input": "Search input.", + "args.search.input.comp.release": "Resolve version of the latest release.", + "args.search.input.comp.snapshot": "Resolve version of the latest snapshot.", # Args start "args.start": "Start a Minecraft version.", "args.start.version": "Version identifier (default to release): {formats}.", @@ -61,6 +73,12 @@ def get(key: str, **kwargs) -> str: "args.start.version.quilt": "quilt:[[:]]", "args.start.version.forge": "forge:[] (forge-version >= 1.5.2)", "args.start.version.neoforge": "neoforge:[] (neoforge-version >= 1.20.1)", + "args.start.version.comp.release": "Start the latest release (default).", + "args.start.version.comp.snapshot": "Start the latest snapshot.", + "args.start.version.comp.fabric": "Start Fabric mod loader with latest release.", + "args.start.version.comp.quilt": "Start Quilt mod loader with latest release.", + "args.start.version.comp.forge": "Start Forge mod loader with latest release.", + "args.start.version.comp.neoforge": "Start NeoForge mod loader with latest release.", "args.start.dry": "Simulate game starting.", "args.start.disable_multiplayer": "Disable the multiplayer buttons (>= 1.16).", "args.start.disable_chat": "Disable the online chat (>= 1.16).", @@ -94,7 +112,7 @@ def get(key: str, **kwargs) -> str: "args.start.username": "Set a custom user name to play.", "args.start.uuid": "Set a custom user UUID to play.", "args.start.server": "Start the game and directly connect to a multiplayer server (>= 1.6).", - "args.start.server_port": "Set the server address port (given with -s, --server, >= 1.6).", + "args.start.server_port": "Set the server port (given with -s, --server, >= 1.6).", # Args login "args.login": "Login into your account and save the session.", "args.login.microsoft": "Login using Microsoft account.", @@ -106,6 +124,10 @@ def get(key: str, **kwargs) -> str: "args.show.about": "Display authors, version and license of PortableMC.", "args.show.auth": "Debug the authentication database and supported services.", "args.show.lang": "Debug the language mappings used for messages translation.", + "args.show.completion": "Print a shell completion script.", + "args.show.completion.shell": "The shell to generate completion script for (default to your current shell).", + "args.show.completion.shell.comp.zsh": "Generate completion script for Zsh.", + "args.show.completion.shell.comp.bash": "Generate completion script for Bash.", # Common "echo": "{echo}", "cancelled": "Cancelled.", diff --git a/portablemc/cli/parse.py b/portablemc/cli/parse.py index 09a7485d..9d0d4ee0 100644 --- a/portablemc/cli/parse.py +++ b/portablemc/cli/parse.py @@ -1,4 +1,4 @@ -from argparse import ArgumentParser, HelpFormatter, ArgumentTypeError, SUPPRESS +from argparse import ArgumentParser, Action, HelpFormatter, ArgumentTypeError, SUPPRESS from pathlib import Path import sys @@ -9,12 +9,7 @@ from .output import Output from .lang import get as _ -from typing import Optional, Type, Tuple, List - -try: - import argcomplete -except ImportError: - argcomplete = None # No auto completion support +from typing import Optional, Type, Tuple, List, Dict # The following classes are only used for type checking and represent a typed namespace # as produced by the arguments registered to the argument parser. @@ -26,6 +21,7 @@ class RootNs: out_kind: str verbose: int # Initialized by main function after argument parsing. + parser: ArgumentParser out: Output context: Context version_manifest: VersionManifest @@ -46,7 +42,7 @@ class StartNs(AuthBaseNs): disable_chat: bool demo: bool resolution: Optional[Tuple[int, int]] - jvm: Optional[str] + jvm: Optional[Path] jvm_args: Optional[str] no_fix: bool fabric_prefix: str @@ -55,7 +51,7 @@ class StartNs(AuthBaseNs): neoforge_prefix: str lwjgl: Optional[str] exclude_lib: Optional[List[LibrarySpecifierFilter]] - include_bin: Optional[List[str]] + include_bin: Optional[List[Path]] temp_login: bool auth_service: str auth_anonymize: bool @@ -72,6 +68,9 @@ class LoginNs(AuthBaseNs): class LogoutNs(AuthBaseNs): email_or_username: str +class ShowCompletionNs(RootNs): + shell: str + def register_common_help(parser: ArgumentParser) -> None: parser.formatter_class = new_help_formatter_class(40) @@ -79,27 +78,32 @@ def register_common_help(parser: ArgumentParser) -> None: def register_common_auth_service(parser: ArgumentParser) -> None: - parser.add_argument("--auth-service", help=_("args.common.auth_service"), default="microsoft", choices=get_auth_services()) + + auth_choices = get_auth_services() + auth_arg = parser.add_argument("--auth-service", help=_("args.common.auth_service"), default="microsoft", choices=auth_choices) + for choice in auth_choices: + add_completion(auth_arg, choice, _(f"args.common.auth_service.comp.{choice}")) + parser.add_argument("--auth-no-browser", help=_("args.common.auth_no_browser"), action="store_true") def register_arguments() -> ArgumentParser: - + parser = ArgumentParser(allow_abbrev=False, prog="portablemc", description=_("args"), add_help=False) register_common_help(parser) - main_dir = parser.add_argument("--main-dir", help=_("args.main_dir"), type=Path) - work_dir = parser.add_argument("--work-dir", help=_("args.work_dir"), type=Path) + + parser.add_argument("--main-dir", help=_("args.main_dir"), type=type_path_dir) + parser.add_argument("--work-dir", help=_("args.work_dir"), type=type_path_dir) parser.add_argument("--timeout", help=_("args.timeout"), type=float) - parser.add_argument("--output", help=_("args.output"), dest="out_kind", choices=get_outputs(), default="human-color" if sys.stdout.isatty() else "human") + + output_choices = get_outputs() + output_default = "human-color" if sys.stdout.isatty() else "human" + output_arg = parser.add_argument("--output", help=_("args.output"), dest="out_kind", choices=output_choices, default=output_default) + for choice in output_choices: + add_completion(output_arg, choice, _(f"args.output.comp.{choice}")) + parser.add_argument("-v", dest="verbose", help=_("args.verbose"), action="count", default=0) register_subcommands(parser.add_subparsers(title="subcommands", dest="subcommand")) - set_directories_completer(main_dir) - set_directories_completer(work_dir) - - # If the "argcomplete" extra dependency is installed, we generate an autocompletion. - # Note: argcomplete is not type-hinted. - if argcomplete is not None: - argcomplete.autocomplete(parser, default_completer=None) # type: ignore return parser @@ -113,20 +117,27 @@ def register_subcommands(subparsers) -> None: def register_search_arguments(parser: ArgumentParser) -> None: + register_common_help(parser) - parser.add_argument("-k", "--kind", help=_("args.search.kind"), default="mojang", choices=get_search_kinds()) - input_arg = parser.add_argument("input", nargs="?", help=_("args.search.input")) - set_choices_completer(input_arg, ("release", "snapshot")) + kind_choices = get_search_kinds() + kind_arg = parser.add_argument("-k", "--kind", help=_("args.search.kind"), default="mojang", choices=kind_choices) + for choice in kind_choices: + add_completion(kind_arg, choice, _(f"args.search.kind.comp.{choice}")) + + input_arg = parser.add_argument("input", nargs="?", help=_("args.search.input")) + add_completion(input_arg, "release", _("args.search.input.comp.release")) + add_completion(input_arg, "snapshot", _("args.search.input.comp.snapshot")) def register_start_arguments(parser: ArgumentParser) -> None: + register_common_help(parser) parser.add_argument("--dry", help=_("args.start.dry"), action="store_true") parser.add_argument("--disable-mp", help=_("args.start.disable_multiplayer"), action="store_true") parser.add_argument("--disable-chat", help=_("args.start.disable_chat"), action="store_true") parser.add_argument("--demo", help=_("args.start.demo"), action="store_true") - parser.add_argument("--resolution", help=_("args.start.resolution"), type=resolution_from_str) - jvm_arg = parser.add_argument("--jvm", help=_("args.start.jvm")) + parser.add_argument("--resolution", help=_("args.start.resolution"), type=type_resolution) + parser.add_argument("--jvm", help=_("args.start.jvm"), type=type_path) parser.add_argument("--jvm-args", help=_("args.start.jvm_args"), metavar="ARGS") parser.add_argument("--no-fix", help=_("args.start.no_fix"), action="store_true") parser.add_argument("--fabric-prefix", help=_("args.start.fabric_prefix"), default="fabric", metavar="PREFIX") @@ -135,31 +146,33 @@ def register_start_arguments(parser: ArgumentParser) -> None: parser.add_argument("--neoforge-prefix", help=_("args.start.neoforge_prefix"), default="neoforge", metavar="PREFIX") parser.add_argument("--lwjgl", help=_("args.start.lwjgl")) parser.add_argument("--exclude-lib", help=_("args.start.exclude_lib"), action="append", metavar="SPEC", type=LibrarySpecifierFilter.from_str) - include_bin_arg = parser.add_argument("--include-bin", help=_("args.start.include_bin"), action="append", metavar="PATH") + parser.add_argument("--include-bin", help=_("args.start.include_bin"), action="append", metavar="PATH", type=type_path) parser.add_argument("--auth-anonymize", help=_("args.start.auth_anonymize"), action="store_true") register_common_auth_service(parser) parser.add_argument("-t", "--temp-login", help=_("args.start.temp_login"), action="store_true") - parser.add_argument("-l", "--login", help=_("args.start.login")) + parser.add_argument("-l", "--login", help=_("args.start.login"), type=type_email_or_username) parser.add_argument("-u", "--username", help=_("args.start.username"), metavar="NAME") parser.add_argument("-i", "--uuid", help=_("args.start.uuid")) - parser.add_argument("-s", "--server", help=_("args.start.server")) - parser.add_argument("-p", "--server-port", type=int, help=_("args.start.server_port"), metavar="PORT") + parser.add_argument("-s", "--server", help=_("args.start.server"), type=type_host) + parser.add_argument("-p", "--server-port", help=_("args.start.server_port"), metavar="PORT") + version_arg = parser.add_argument("version", nargs="?", default="release", help=_("args.start.version", formats=", ".join(map(lambda s: _(f"args.start.version.{s}"), ("standard", "fabric", "quilt", "forge", "neoforge"))))) - set_files_completer(jvm_arg) - set_files_completer(include_bin_arg) - set_choices_completer(version_arg, ("release", "snapshot", "fabric:", "quilt:", "forge:", "neoforge:")) + for standard in ("release", "snapshot"): + add_completion(version_arg, standard, _(f"args.start.version.comp.{standard}")) + for loader in ("fabric", "quilt", "forge", "neoforge"): + add_completion(version_arg, f"{loader}:", _(f"args.start.version.comp.{loader}")) def register_login_arguments(parser: ArgumentParser) -> None: register_common_help(parser) register_common_auth_service(parser) - parser.add_argument("email_or_username") + parser.add_argument("email_or_username", type=type_email_or_username) def register_logout_arguments(parser: ArgumentParser) -> None: register_common_help(parser) register_common_auth_service(parser) - parser.add_argument("email_or_username") + parser.add_argument("email_or_username", type=type_email_or_username) def register_show_arguments(parser: ArgumentParser) -> None: @@ -169,6 +182,16 @@ def register_show_arguments(parser: ArgumentParser) -> None: subparsers.add_parser("about", help=_("args.show.about"), add_help=False) subparsers.add_parser("auth", help=_("args.show.auth"), add_help=False) subparsers.add_parser("lang", help=_("args.show.lang"), add_help=False) + register_show_completion_arguments(subparsers.add_parser("completion", help=_("args.show.completion"), add_help=False)) + + +def register_show_completion_arguments(parser: ArgumentParser) -> None: + + register_common_help(parser) + arg_shell = parser.add_argument("--shell", default="zsh", choices=get_completion_shells(), help=_("args.show.completion.shell")) + + for shell in get_completion_shells(): + add_completion(arg_shell, shell, _(f"args.show.completion.shell.comp.{shell}")) def new_help_formatter_class(max_help_position: int) -> Type[HelpFormatter]: @@ -183,31 +206,42 @@ def __init__(self, prog): def get_outputs() -> List[str]: return ["human-color", "human", "machine"] - def get_search_kinds() -> List[str]: return ["mojang", "local", "forge", "fabric", "quilt"] - def get_auth_services() -> List[str]: return ["microsoft", "yggdrasil"] +def get_completion_shells() -> List[str]: + return ["bash", "zsh"] + -def resolution_from_str(s: str) -> Tuple[int, int]: +def type_path(s: str) -> Path: + return Path(s) + +def type_path_dir(s: str) -> Path: + return Path(s) + +def type_resolution(s: str) -> Tuple[int, int]: parts = s.split("x") if len(parts) == 2: return (int(parts[0]), int(parts[1])) else: raise ArgumentTypeError(_("args.start.resolution.invalid", given=s)) +def type_email_or_username(s: str) -> str: + return s + +def type_host(s: str) -> str: + return s -def set_choices_completer(action, choices): - if argcomplete is not None: - action.completer = argcomplete.completers.ChoicesCompleter(choices) -def set_files_completer(action): - if argcomplete is not None: - action.completer = argcomplete.completers.FilesCompleter() +def add_completion(action: Action, name: str, description: str): + """Add a completion for this action, this is used by 'complete' module. + """ + if not hasattr(action, "_pmc_completions"): + action._pmc_completions = {} # type: ignore + action._pmc_completions[name] = description # type: ignore -def set_directories_completer(action): - if argcomplete is not None: - action.completer = argcomplete.completers.DirectoriesCompleter() +def get_completions(action: Action) -> Dict[str, str]: + return getattr(action, "_pmc_completions", {}) diff --git a/pyproject.toml b/pyproject.toml index 1467c2bd..bd83bdca 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -24,7 +24,6 @@ classifiers = [ # version. See https://devguide.python.org/versions/ python = ">=3.8" certifi = { version = "*", optional = true } -argcomplete = { version = "^3.1.5", optional = true } [tool.poetry.group.test.dependencies] pytest = "*" @@ -36,5 +35,3 @@ portablemc = "portablemc.cli:main" [tool.poetry.extras] certifi = ["certifi"] -argcomplete = ["argcomplete"] - From 6e9f543bb510cd62079b628573c4b6fdc4d234e6 Mon Sep 17 00:00:00 2001 From: Theo Rozier Date: Sun, 28 Jan 2024 14:59:24 +0100 Subject: [PATCH 12/30] Removed useless Path construction --- portablemc/cli/__init__.py | 1 - 1 file changed, 1 deletion(-) diff --git a/portablemc/cli/__init__.py b/portablemc/cli/__init__.py index 1bfa09ee..cb4460ba 100644 --- a/portablemc/cli/__init__.py +++ b/portablemc/cli/__init__.py @@ -376,7 +376,6 @@ def filter_libraries(libs: Dict[LibrarySpecifier, Any]) -> None: # Included binaries if ns.include_bin is not None: for bin_path in ns.include_bin: - bin_path = Path(bin_path) if not bin_path.is_file(): ns.out.task("FAILED", "start.additional_binary_not_found", path=bin_path) ns.out.finish() From 010b015890ad3c97fe01c0d5ca6d3c1541bc1ade Mon Sep 17 00:00:00 2001 From: Theo Rozier Date: Sun, 28 Jan 2024 19:24:51 +0100 Subject: [PATCH 13/30] Working on bash completion script generation (95% finished, missing choices). --- portablemc/cli/__init__.py | 5 +- portablemc/cli/complete.py | 150 ++++++++++++++++++++++++++++++++----- 2 files changed, 136 insertions(+), 19 deletions(-) diff --git a/portablemc/cli/__init__.py b/portablemc/cli/__init__.py index cb4460ba..233481ee 100644 --- a/portablemc/cli/__init__.py +++ b/portablemc/cli/__init__.py @@ -13,7 +13,6 @@ import sys from .parse import register_arguments, RootNs, SearchNs, StartNs, LoginNs, LogoutNs, AuthBaseNs, ShowCompletionNs -from .complete import gen_zsh_completion from .util import format_locale_date, format_time, format_number, anonymize_email from .output import Output, HumanOutput, MachineOutput, OutputTable @@ -543,8 +542,12 @@ def cmd_show_lang(ns: RootNs): def cmd_show_completion(ns: ShowCompletionNs): + from .complete import gen_zsh_completion, gen_bash_completion + if ns.shell == "zsh": content = gen_zsh_completion(ns.parser) + elif ns.shell == "bash": + content = gen_bash_completion(ns.parser) else: raise RuntimeError diff --git a/portablemc/cli/complete.py b/portablemc/cli/complete.py index c6fed4c3..888c6c8e 100644 --- a/portablemc/cli/complete.py +++ b/portablemc/cli/complete.py @@ -10,27 +10,23 @@ from typing import Dict, Tuple, cast -def escape_zsh(s: str, *, space = False) -> str: - s = s.replace("'", "''").replace("[", "\\[").replace("]", "\\]").replace(":", "\\:") - if space: - s = s.replace(" ", "\\ ") - return s - - def gen_zsh_completion(parser: ArgumentParser) -> str: buffer = StringIO() - gen_zsh_parser_completion(parser, buffer, "_complete_portablemc") - buffer.write("compdef _complete_portablemc portablemc\n") + gen_zsh_parser_completion(parser, buffer, "_portablemc") + buffer.write("compdef _portablemc portablemc\n") return buffer.getvalue() - def gen_zsh_parser_completion(parser: ArgumentParser, buffer: StringIO, function: str): + # Sources: + # - https://zsh.sourceforge.io/Doc/Release/Completion-Widgets.html + # - https://zsh.sourceforge.io/Doc/Release/Completion-System.html + commands: Dict[str, Tuple[str, ArgumentParser]] = {} completions: Dict[str, Dict[str, str]] = {} buffer.write(function) - buffer.write("() {\n") + buffer.write(" () {\n") buffer.write(" local curcontext=$curcontext state line\n") buffer.write(" integer ret=1\n") @@ -63,8 +59,8 @@ def gen_zsh_parser_completion(parser: ArgumentParser, buffer: StringIO, function elif action.type == type_host: zsh_action = ": :_hosts" elif len(action_completions): - zsh_action = f": :->action_{action.dest}" - completions[f"action_{action.dest}"] = action_completions + zsh_action = f": :->arg_{action.dest}" + completions[f"arg_{action.dest}"] = action_completions elif isinstance(action, (_HelpAction, _StoreConstAction, _AppendConstAction)): zsh_action = "" @@ -99,15 +95,15 @@ def gen_zsh_parser_completion(parser: ArgumentParser, buffer: StringIO, function if len(commands): buffer.write(" command)\n") buffer.write(" local -a commands=(\n") - for name, (description, parser) in commands.items(): - buffer.write(f" '{name}:{escape_zsh(description)}'\n") + for name, (cmd_description, cmd_parser) in commands.items(): + buffer.write(f" '{name}:{escape_zsh(cmd_description)}'\n") buffer.write(" )\n") buffer.write(" _describe -t commands command commands && ret=0\n") buffer.write(" ;;\n") buffer.write(" option)\n") buffer.write(" case $line[1] in\n") - for name, (description, parser) in commands.items(): + for name, (cmd_description, cmd_parser) in commands.items(): buffer.write(f" {name}) {function}_{name} ;;\n") buffer.write(" esac\n") buffer.write(" ;;\n") @@ -128,5 +124,123 @@ def gen_zsh_parser_completion(parser: ArgumentParser, buffer: StringIO, function buffer.write("}\n\n") - for name, (description, parser) in commands.items(): - gen_zsh_parser_completion(parser, buffer, f"{function}_{name}") + for cmd_name, (cmd_description, cmd_parser) in commands.items(): + gen_zsh_parser_completion(cmd_parser, buffer, f"{function}_{cmd_name}") + +def escape_zsh(s: str) -> str: + return s.replace("'", "''").replace("[", "\\[").replace("]", "\\]").replace(":", "\\:") + + +def gen_bash_completion(parser: ArgumentParser) -> str: + buffer = StringIO() + buffer.write("#/usr/bin/env bash\n\n") + gen_bash_parser_completion(parser, buffer, "_portablemc") + buffer.write("\ncomplete -o filenames -o nosort -F _portablemc portablemc\n") + return buffer.getvalue() + +def gen_bash_parser_completion(parser: ArgumentParser, buffer: StringIO, function: str): + + # Note: We use single quote in this function because we double quote in bash. + # Sources: + # - https://www.gnu.org/software/bash/manual/html_node/Programmable-Completion-Builtins.html + # - https://www.gnu.org/savannah-checkouts/gnu/bash/manual/bash.html + + buffer.write(function) + buffer.write(' () {\n') + + # We guess that there we always be two argument, the command and the argument to comp. + buffer.write(' local index="$(( COMP_CWORD - 1 ))"\n') + buffer.write(' local words=(${COMP_WORDS[@]:1})\n') + buffer.write(' local word="${words[$index]}"\n') + + # Start by finding if there are sub parsers. + commands: Dict[str, ArgumentParser] = {} + for action in parser._actions: + if isinstance(action, _SubParsersAction): + commands.update(action.choices) + elif len(action.option_strings): + buffer.write(f' local arg_{action.dest}="{" ".join(action.option_strings)}"\n') + + # Write a loop to find potential sub-command, and construct arguments list. + # We overwrite the COMP_ variables because we don't use them after loop. + buffer.write(' for i in ${!words[@]}; do\n') + + # Start by sub-commands... + if len(commands): + buffer.write(" if (( i < index )); then\n") + buffer.write(" COMP_WORDS=(${words[@]:$i})\n") + buffer.write(" COMP_CWORD=$((index - i))\n") + buffer.write(' case "${words[$i]}" in\n') + for cmd_name, cmd_parser in commands.items(): + buffer.write(f' {cmd_name}) {function}_{cmd_name}; return ;;\n') + buffer.write(" esac\n") + buffer.write(" fi\n") + + # Then arguments... + buffer.write(' case "${words[$i]}" in\n') + for action in parser._actions: + if isinstance(action, _SubParsersAction): + pass + elif len(action.option_strings): + buffer.write( " ") + buffer.write( " | ".join(f'"{option}"' for option in action.option_strings)) + buffer.write(f') arg_{action.dest}="" ;;\n') + buffer.write(" esac\n") + + buffer.write(" done\n") + + # Special case for options with associated value. + buffer.write(' if (( index >= 1 )); then\n') + buffer.write(' case ${words[$(( index - 1 ))]} in\n') + + for action in parser._actions: + + if isinstance(action, _StoreAction): + + action_completions = get_completions(action) + if action.choices is not None: + for choice in action.choices: + if choice not in action_completions: + action_completions[choice] = "" + + if len(action.option_strings): + + buffer.write(" ") + buffer.write(" | ".join(f'"{option}"' for option in action.option_strings)) + buffer.write(")\n") + + reply = "" + + if action.type == type_path: + reply = '$(compgen -o plusdirs -f -- "$word")' + elif action.type == type_path_dir: + reply = '$(compgen -o plusdirs -d -- "$word")' + elif action.type == type_email_or_username: + pass + elif action.type == type_host: + reply = '$(compgen -A hostname -- "$word")' + elif len(action_completions): + reply = f'$(compgen -W "{" ".join(action_completions.keys())}" -- "$word")' + + buffer.write(f" COMPREPLY=({reply})\n") + buffer.write( " return\n") + buffer.write( " ;;\n") + + buffer.write(" esac\n") + buffer.write(" fi\n") + + # This is the default reply for argument names. + buffer.write(' COMPREPLY=($(compgen -W "') + for cmd_name, cmd_parser in commands.items(): + buffer.write(f"{cmd_name} ") + for action in parser._actions: + if isinstance(action, _SubParsersAction): + pass + elif len(action.option_strings): + buffer.write(f"$arg_{action.dest} ") + buffer.write('" -- "$word"))\n') + + buffer.write('}\n\n') + + for cmd_name, cmd_parser in commands.items(): + gen_bash_parser_completion(cmd_parser, buffer, f"{function}_{cmd_name}") From 9e9c1e37c0ab2490c8addcf203d78837c0d7c9d7 Mon Sep 17 00:00:00 2001 From: Theo Rozier Date: Sun, 28 Jan 2024 20:04:52 +0100 Subject: [PATCH 14/30] Finished bash completion script (#111). --- portablemc/cli/complete.py | 24 ++++++++++++------------ 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/portablemc/cli/complete.py b/portablemc/cli/complete.py index 888c6c8e..74e029c4 100644 --- a/portablemc/cli/complete.py +++ b/portablemc/cli/complete.py @@ -144,9 +144,14 @@ def gen_bash_parser_completion(parser: ArgumentParser, buffer: StringIO, functio # Sources: # - https://www.gnu.org/software/bash/manual/html_node/Programmable-Completion-Builtins.html # - https://www.gnu.org/savannah-checkouts/gnu/bash/manual/bash.html - + # + # Current limitations of the bash completer: + # - Choices for positional arguments are not support, luckily the launcher don't use + # such construct. + # - Short arguments cannot be completed when stacked. + buffer.write(function) - buffer.write(' () {\n') + buffer.write(' ()\n{\n') # We guess that there we always be two argument, the command and the argument to comp. buffer.write(' local index="$(( COMP_CWORD - 1 ))"\n') @@ -159,6 +164,7 @@ def gen_bash_parser_completion(parser: ArgumentParser, buffer: StringIO, functio if isinstance(action, _SubParsersAction): commands.update(action.choices) elif len(action.option_strings): + # Named argument buffer.write(f' local arg_{action.dest}="{" ".join(action.option_strings)}"\n') # Write a loop to find potential sub-command, and construct arguments list. @@ -179,8 +185,8 @@ def gen_bash_parser_completion(parser: ArgumentParser, buffer: StringIO, functio # Then arguments... buffer.write(' case "${words[$i]}" in\n') for action in parser._actions: - if isinstance(action, _SubParsersAction): - pass + if isinstance(action, (_SubParsersAction, _CountAction)): + pass # Count action are not limited in number elif len(action.option_strings): buffer.write( " ") buffer.write( " | ".join(f'"{option}"' for option in action.option_strings)) @@ -197,12 +203,6 @@ def gen_bash_parser_completion(parser: ArgumentParser, buffer: StringIO, functio if isinstance(action, _StoreAction): - action_completions = get_completions(action) - if action.choices is not None: - for choice in action.choices: - if choice not in action_completions: - action_completions[choice] = "" - if len(action.option_strings): buffer.write(" ") @@ -219,8 +219,8 @@ def gen_bash_parser_completion(parser: ArgumentParser, buffer: StringIO, functio pass elif action.type == type_host: reply = '$(compgen -A hostname -- "$word")' - elif len(action_completions): - reply = f'$(compgen -W "{" ".join(action_completions.keys())}" -- "$word")' + elif action.choices is not None: + reply = f'$(compgen -W "{" ".join(action.choices)}" -- "$word")' buffer.write(f" COMPREPLY=({reply})\n") buffer.write( " return\n") From 0fc08692b7875dfb1fd70272a8c39a79dbcae38c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Th=C3=A9o=20Rozier?= Date: Sun, 28 Jan 2024 23:27:32 +0100 Subject: [PATCH 15/30] Default completion shell depends on SHELL env --- portablemc/cli/parse.py | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/portablemc/cli/parse.py b/portablemc/cli/parse.py index 9d0d4ee0..8b928028 100644 --- a/portablemc/cli/parse.py +++ b/portablemc/cli/parse.py @@ -1,6 +1,7 @@ from argparse import ArgumentParser, Action, HelpFormatter, ArgumentTypeError, SUPPRESS from pathlib import Path import sys +import os from portablemc.standard import Context, VersionManifest from portablemc.auth import AuthDatabase @@ -188,10 +189,16 @@ def register_show_arguments(parser: ArgumentParser) -> None: def register_show_completion_arguments(parser: ArgumentParser) -> None: register_common_help(parser) - arg_shell = parser.add_argument("--shell", default="zsh", choices=get_completion_shells(), help=_("args.show.completion.shell")) - for shell in get_completion_shells(): - add_completion(arg_shell, shell, _(f"args.show.completion.shell.comp.{shell}")) + # The shell argument is only required if the shell cannot be determined. + shell_choices = get_completion_shells() + shell_default = os.getenv("SHELL") + if shell_default is not None and shell_default not in shell_choices: + shell_default = None + shell_arg = parser.add_argument("--shell", required=shell_default is None, default=shell_default, choices=shell_choices, help=_("args.show.completion.shell")) + + for choice in shell_choices: + add_completion(shell_arg, choice, _(f"args.show.completion.shell.comp.{choice}")) def new_help_formatter_class(max_help_position: int) -> Type[HelpFormatter]: From d0c11140bbf2293860ca26135f85b9305dcf72d2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Th=C3=A9o=20Rozier?= Date: Mon, 29 Jan 2024 12:19:15 +0100 Subject: [PATCH 16/30] Added auto-loaded Zsh completion by default --- portablemc/cli/__init__.py | 4 +++- portablemc/cli/complete.py | 9 +++++++-- portablemc/cli/parse.py | 2 +- 3 files changed, 11 insertions(+), 4 deletions(-) diff --git a/portablemc/cli/__init__.py b/portablemc/cli/__init__.py index 233481ee..2a68567d 100644 --- a/portablemc/cli/__init__.py +++ b/portablemc/cli/__init__.py @@ -545,7 +545,9 @@ def cmd_show_completion(ns: ShowCompletionNs): from .complete import gen_zsh_completion, gen_bash_completion if ns.shell == "zsh": - content = gen_zsh_completion(ns.parser) + content = gen_zsh_completion(ns.parser, False) + elif ns.shell == "zsh-eval": + content = gen_zsh_completion(ns.parser, True) elif ns.shell == "bash": content = gen_bash_completion(ns.parser) else: diff --git a/portablemc/cli/complete.py b/portablemc/cli/complete.py index 74e029c4..3618d51b 100644 --- a/portablemc/cli/complete.py +++ b/portablemc/cli/complete.py @@ -10,10 +10,15 @@ from typing import Dict, Tuple, cast -def gen_zsh_completion(parser: ArgumentParser) -> str: +def gen_zsh_completion(parser: ArgumentParser, evaluated: bool) -> str: buffer = StringIO() + if not evaluated: + buffer.write("#compdef portablemc\n\n") gen_zsh_parser_completion(parser, buffer, "_portablemc") - buffer.write("compdef _portablemc portablemc\n") + if not evaluated: + buffer.write("_portablemc\n") + else: + buffer.write("compdef _portablemc portablemc\n") return buffer.getvalue() def gen_zsh_parser_completion(parser: ArgumentParser, buffer: StringIO, function: str): diff --git a/portablemc/cli/parse.py b/portablemc/cli/parse.py index 8b928028..be2a8c79 100644 --- a/portablemc/cli/parse.py +++ b/portablemc/cli/parse.py @@ -220,7 +220,7 @@ def get_auth_services() -> List[str]: return ["microsoft", "yggdrasil"] def get_completion_shells() -> List[str]: - return ["bash", "zsh"] + return ["bash", "zsh", "zsh-eval"] def type_path(s: str) -> Path: From a6abb6eb25df5878c20c33cc54534517863df3fb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Th=C3=A9o=20Rozier?= Date: Mon, 29 Jan 2024 12:52:42 +0100 Subject: [PATCH 17/30] Added precise documentation for completion command. I will also rework documentation of other commands. --- portablemc/cli/lang.py | 30 +++++++++++++++++++++++++----- portablemc/cli/parse.py | 16 +++++++++------- 2 files changed, 34 insertions(+), 12 deletions(-) diff --git a/portablemc/cli/lang.py b/portablemc/cli/lang.py index 0522ff78..e36a47a1 100644 --- a/portablemc/cli/lang.py +++ b/portablemc/cli/lang.py @@ -55,7 +55,7 @@ def get(key: str, **kwargs) -> str: "args.common.auth_service.comp.yggdrasil": "Mojang authentication (deprecated).", "args.common.auth_no_browser": "Prevent the authentication service to open your system's web browser.", # Args search - "args.search": "Search for Minecraft versions.", + "args.search": "Search for versions.", "args.search.kind": "Select the kind of search to operate.", "args.search.kind.comp.mojang": "Search for official Mojang versions (default).", "args.search.kind.comp.local": "Search for locally installed versions.", @@ -66,7 +66,7 @@ def get(key: str, **kwargs) -> str: "args.search.input.comp.release": "Resolve version of the latest release.", "args.search.input.comp.snapshot": "Resolve version of the latest snapshot.", # Args start - "args.start": "Start a Minecraft version.", + "args.start": "Start the game.", "args.start.version": "Version identifier (default to release): {formats}.", "args.start.version.standard": "release|snapshot|", "args.start.version.fabric": "fabric:[[:]]", @@ -120,14 +120,34 @@ def get(key: str, **kwargs) -> str: "args.logout": "Logout and invalidate a session.", "args.logout.microsoft": "Logout from a Microsoft account.", # Args show - "args.show": "Show and debug various data.", + "args.show": "Show, debug and generate data unrelated to the game.", "args.show.about": "Display authors, version and license of PortableMC.", "args.show.auth": "Debug the authentication database and supported services.", "args.show.lang": "Debug the language mappings used for messages translation.", "args.show.completion": "Print a shell completion script.", - "args.show.completion.shell": "The shell to generate completion script for (default to your current shell).", - "args.show.completion.shell.comp.zsh": "Generate completion script for Zsh.", + "args.show.completion._": + # Part of this description are from 'rustup' completion description. + " This command prints a shell completion script in the terminal.\n" + " The installation of this completion script depends on you shell and is explained below.\n\n" + " BASH:\n\n" + " Completion files are commonly stored in /etc/bash_completion.d/ for system-wide commands,\n" + " but can be stored in ~/.local/share/bash-completion/completions for user-specific commands.\n" + " You can run the following commands to generate the file:\n\n" + " $ mkdir -p ~/.local/share/bash-completion/completions\n" + " $ portablemc show completion --shell bash > ~/.local/share/bash-completion/completions/portablemc\n\n" + " You can also dynamically evaluate the script, but it may slow your shell startup:\n\n" + " $ eval \"$(portablemc show completion --shell bash)\"\n\n" + " ZSH:\n\n" + " ZSH completions are commonly stored in any directory listed in your $fpath variable.\n" + " To use these completions, you must either add the generated script to one of those\n" + " directories, or add your own to this list. Once you chose a $fpath directory:\n\n" + " $ portablemc show completion --shell zsh > your-dir/_portablemc\n\n" + " You can also dynamically evaluate a script, but it may slow your shell startup:\n\n" + " $ eval \"$(portablemc show completion --shell zsh-eval)\"\n\n", + "args.show.completion.shell": "The shell to generate completion script for (default to your current shell, required if not found).", "args.show.completion.shell.comp.bash": "Generate completion script for Bash.", + "args.show.completion.shell.comp.zsh": "Generate completion script for Zsh.", + "args.show.completion.shell.comp.zsh-eval": "Generate completion script for Zsh to inline (eval).", # Common "echo": "{echo}", "cancelled": "Cancelled.", diff --git a/portablemc/cli/parse.py b/portablemc/cli/parse.py index be2a8c79..2a03b9a9 100644 --- a/portablemc/cli/parse.py +++ b/portablemc/cli/parse.py @@ -1,4 +1,5 @@ -from argparse import ArgumentParser, Action, HelpFormatter, ArgumentTypeError, SUPPRESS +from argparse import ArgumentParser, Action, SUPPRESS, \ + HelpFormatter, RawDescriptionHelpFormatter, ArgumentTypeError from pathlib import Path import sys import os @@ -110,11 +111,11 @@ def register_arguments() -> ArgumentParser: def register_subcommands(subparsers) -> None: - register_search_arguments(subparsers.add_parser("search", help=_("args.search"), add_help=False)) - register_start_arguments(subparsers.add_parser("start", help=_("args.start"), add_help=False)) - register_login_arguments(subparsers.add_parser("login", help=_("args.login"), add_help=False)) - register_logout_arguments(subparsers.add_parser("logout", help=_("args.logout"), add_help=False)) - register_show_arguments(subparsers.add_parser("show", help=_("args.show"), add_help=False)) + register_search_arguments(subparsers.add_parser("search", help=_("args.search"), description=_("args.search"), add_help=False)) + register_start_arguments(subparsers.add_parser("start", help=_("args.start"), description=_("args.start"), add_help=False)) + register_login_arguments(subparsers.add_parser("login", help=_("args.login"), description=_("args.login"), add_help=False)) + register_logout_arguments(subparsers.add_parser("logout", help=_("args.logout"), description=_("args.logout"), add_help=False)) + register_show_arguments(subparsers.add_parser("show", help=_("args.show"), description=_("args.show"), add_help=False)) def register_search_arguments(parser: ArgumentParser) -> None: @@ -188,6 +189,7 @@ def register_show_arguments(parser: ArgumentParser) -> None: def register_show_completion_arguments(parser: ArgumentParser) -> None: + parser.description = _("args.show.completion._") register_common_help(parser) # The shell argument is only required if the shell cannot be determined. @@ -203,7 +205,7 @@ def register_show_completion_arguments(parser: ArgumentParser) -> None: def new_help_formatter_class(max_help_position: int) -> Type[HelpFormatter]: - class CustomHelpFormatter(HelpFormatter): + class CustomHelpFormatter(RawDescriptionHelpFormatter): def __init__(self, prog): super().__init__(prog, max_help_position=max_help_position) From 8c8a2fb864b41420395554e9beb1f84466b7ebed Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Th=C3=A9o=20Rozier?= Date: Mon, 29 Jan 2024 16:18:28 +0100 Subject: [PATCH 18/30] Improved links in README, using absolute URIs when file is not shipped with the README when packaged. --- README.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 3ed479ca..b42fd531 100644 --- a/README.md +++ b/README.md @@ -3,9 +3,9 @@ A fast, reliable and cross-platform command-line Minecraft launcher and API for This launcher is compatible with the official Minecraft launcher's version specification. It also includes fast installation of common mod loaders such as Fabric, Forge and Quilt. -![PyPI - Version](https://img.shields.io/pypi/v/portablemc?label=PyPI%20version&style=flat-square)  ![PyPI - Downloads](https://img.shields.io/pypi/dm/portablemc?label=PyPI%20downloads&style=flat-square) +[![PyPI - Version](https://img.shields.io/pypi/v/portablemc?label=PyPI%20version&style=flat-square)  ![PyPI - Downloads](https://img.shields.io/pypi/dm/portablemc?label=PyPI%20downloads&style=flat-square)](https://pypi.org/project/portablemc/) -![illustration](doc/illustration.png) +![illustration](https://github.com/mindstorm38/portablemc/blob/main/doc/illustration.png) *This launcher is tested for Python 3.8, 3.9, 3.10, 3.11, 3.12.* @@ -28,7 +28,7 @@ It also includes fast installation of common mod loaders such as Fabric, Forge a - [Setup environment](#setup-environment) - [Contributors](#contributors) - [Sponsors](#sponsors) -- [API Documentation (v4) ⇗](doc/API.md) +- [API Documentation (v4) ⇗](https://github.com/mindstorm38/portablemc/blob/main/doc/API.md) ## Installation This launcher can be installed using `pip`. On some linux distribution you might have to From 6b9c7de76295df7c774775ea2819e55d5f3fb6b9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Th=C3=A9o=20Rozier?= Date: Mon, 29 Jan 2024 17:39:22 +0100 Subject: [PATCH 19/30] Improved help/doc --- README.md | 4 ++-- portablemc/cli/lang.py | 50 +++++++++++++++++++++++++---------------- portablemc/cli/parse.py | 13 ++++++----- 3 files changed, 40 insertions(+), 27 deletions(-) diff --git a/README.md b/README.md index b42fd531..9d488adb 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,7 @@ # Portable Minecraft Launcher A fast, reliable and cross-platform command-line Minecraft launcher and API for developers. -This launcher is compatible with the official Minecraft launcher's version specification. -It also includes fast installation of common mod loaders such as Fabric, Forge and Quilt. +Including fast and easy installation of common mod loaders such as Fabric, Forge, NeoForge and Quilt. +This launcher is compatible with the standard Minecraft directories. [![PyPI - Version](https://img.shields.io/pypi/v/portablemc?label=PyPI%20version&style=flat-square)  ![PyPI - Downloads](https://img.shields.io/pypi/dm/portablemc?label=PyPI%20downloads&style=flat-square)](https://pypi.org/project/portablemc/) diff --git a/portablemc/cli/lang.py b/portablemc/cli/lang.py index e36a47a1..93ead0f6 100644 --- a/portablemc/cli/lang.py +++ b/portablemc/cli/lang.py @@ -34,10 +34,11 @@ def get(key: str, **kwargs) -> str: lang = { # Args root - "args": "A fast, reliable and cross-platform command-line Minecraft launcher and API " - "for developers. This launcher is compatible with the official Minecraft " - "launcher's version specification. It also includes fast installation of " - "common mod loaders such as Fabric, Forge and Quilt.", + "args._": + " A fast, reliable and cross-platform command-line Minecraft launcher and API\n" + " for developers. Including fast and easy installation of common mod loaders such\n" + " as Fabric, Forge, NeoForge and Quilt. This launcher is compatible with the\n" + " standard Minecraft directories.\n\n", "args.main_dir": "Set the main directory where libraries, assets and versions.", "args.work_dir": "Set the working directory where the game run and place for examples " "saves, screenshots (and resources for legacy versions), it also store " @@ -47,7 +48,8 @@ def get(key: str, **kwargs) -> str: "args.output.comp.human-color": "Human readable output with color.", "args.output.comp.human": "Human readable output.", "args.output.comp.machine": "Machine readable output.", - "args.verbose": "Enable verbose output. The more -v argument you put, the more verbose the launcher will be, depending on subcommands' support (usually -v, -vv, -vvv).", + "args.verbose": "Enable verbose output. The more -v argument you put, the more verbose " + "the launcher will be, depending on subcommands' support (usually -v, -vv, -vvv).", # Args common langs "args.common.help": "Show this help message and exit.", "args.common.auth_service": "Authentication service type to use for logging in the game.", @@ -56,6 +58,15 @@ def get(key: str, **kwargs) -> str: "args.common.auth_no_browser": "Prevent the authentication service to open your system's web browser.", # Args search "args.search": "Search for versions.", + "args.search._": + " Search for versions, by default this command will search for official Mojang version\n" + " but you can change this behavior and search for local or mod loaders versions with the\n" + " -k (--kind) argument. Note that the displayed table layout depends on the version kind.\n" + " There is a special case when using version aliases 'release' or 'snapshot', in such case\n" + " the version alias is resolved and the real version is displayed. If no filter is given,\n" + " all results are displayed.\n\n" + " $ portablemc search\n" + " $ portablemc search release\n", "args.search.kind": "Select the kind of search to operate.", "args.search.kind.comp.mojang": "Search for official Mojang versions (default).", "args.search.kind.comp.local": "Search for locally installed versions.", @@ -108,7 +119,8 @@ def get(key: str, **kwargs) -> str: "args.start.auth_anonymize": "Anonymize your email or username for authentication messages.", "args.start.temp_login": "Flag used with -l (--login) to tell launcher not to cache your session if " "not already cached, disabled by default.", - "args.start.login": "Use a email (or deprecated username) to authenticate using selected service (with --auth-service, also overrides --username and --uuid).", + "args.start.login": "Use a email (or deprecated username) to authenticate using selected " + "service (with --auth-service, also overrides --username and --uuid).", "args.start.username": "Set a custom user name to play.", "args.start.uuid": "Set a custom user UUID to play.", "args.start.server": "Start the game and directly connect to a multiplayer server (>= 1.6).", @@ -130,20 +142,20 @@ def get(key: str, **kwargs) -> str: " This command prints a shell completion script in the terminal.\n" " The installation of this completion script depends on you shell and is explained below.\n\n" " BASH:\n\n" - " Completion files are commonly stored in /etc/bash_completion.d/ for system-wide commands,\n" - " but can be stored in ~/.local/share/bash-completion/completions for user-specific commands.\n" - " You can run the following commands to generate the file:\n\n" - " $ mkdir -p ~/.local/share/bash-completion/completions\n" - " $ portablemc show completion --shell bash > ~/.local/share/bash-completion/completions/portablemc\n\n" - " You can also dynamically evaluate the script, but it may slow your shell startup:\n\n" - " $ eval \"$(portablemc show completion --shell bash)\"\n\n" + " Completion files are commonly stored in '/etc/bash_completion.d/' for system-wide commands,\n" + " but can be stored in '~/.local/share/bash-completion/completions' for user-specific commands.\n" + " You can run the following commands to generate the file:\n\n" + " $ mkdir -p ~/.local/share/bash-completion/completions\n" + " $ portablemc show completion --shell bash > ~/.local/share/bash-completion/completions/portablemc\n\n" + " You can also dynamically evaluate the script, but it may slow your shell startup:\n\n" + " $ eval \"$(portablemc show completion --shell bash)\"\n\n" " ZSH:\n\n" - " ZSH completions are commonly stored in any directory listed in your $fpath variable.\n" - " To use these completions, you must either add the generated script to one of those\n" - " directories, or add your own to this list. Once you chose a $fpath directory:\n\n" - " $ portablemc show completion --shell zsh > your-dir/_portablemc\n\n" - " You can also dynamically evaluate a script, but it may slow your shell startup:\n\n" - " $ eval \"$(portablemc show completion --shell zsh-eval)\"\n\n", + " Zsh completions are commonly stored in any directory listed in your '$fpath' variable.\n" + " To use these completions, you must either add the generated script to one of those\n" + " directories, or add your own to this list. Once you chose a '$fpath' directory:\n\n" + " $ portablemc show completion --shell zsh > your-dir/_portablemc\n\n" + " You can also dynamically evaluate a script, but it may slow your shell startup:\n\n" + " $ eval \"$(portablemc show completion --shell zsh-eval)\"\n\n", "args.show.completion.shell": "The shell to generate completion script for (default to your current shell, required if not found).", "args.show.completion.shell.comp.bash": "Generate completion script for Bash.", "args.show.completion.shell.comp.zsh": "Generate completion script for Zsh.", diff --git a/portablemc/cli/parse.py b/portablemc/cli/parse.py index 2a03b9a9..01f7e4b7 100644 --- a/portablemc/cli/parse.py +++ b/portablemc/cli/parse.py @@ -91,7 +91,7 @@ def register_common_auth_service(parser: ArgumentParser) -> None: def register_arguments() -> ArgumentParser: - parser = ArgumentParser(allow_abbrev=False, prog="portablemc", description=_("args"), add_help=False) + parser = ArgumentParser(allow_abbrev=False, prog="portablemc", description=_("args._"), add_help=False) register_common_help(parser) parser.add_argument("--main-dir", help=_("args.main_dir"), type=type_path_dir) @@ -111,15 +111,16 @@ def register_arguments() -> ArgumentParser: def register_subcommands(subparsers) -> None: - register_search_arguments(subparsers.add_parser("search", help=_("args.search"), description=_("args.search"), add_help=False)) - register_start_arguments(subparsers.add_parser("start", help=_("args.start"), description=_("args.start"), add_help=False)) - register_login_arguments(subparsers.add_parser("login", help=_("args.login"), description=_("args.login"), add_help=False)) - register_logout_arguments(subparsers.add_parser("logout", help=_("args.logout"), description=_("args.logout"), add_help=False)) - register_show_arguments(subparsers.add_parser("show", help=_("args.show"), description=_("args.show"), add_help=False)) + register_search_arguments(subparsers.add_parser("search", help=_("args.search"), add_help=False)) + register_start_arguments(subparsers.add_parser("start", help=_("args.start"), add_help=False)) + register_login_arguments(subparsers.add_parser("login", help=_("args.login"), add_help=False)) + register_logout_arguments(subparsers.add_parser("logout", help=_("args.logout"), add_help=False)) + register_show_arguments(subparsers.add_parser("show", help=_("args.show"), add_help=False)) def register_search_arguments(parser: ArgumentParser) -> None: + parser.description = _("args.search._") register_common_help(parser) kind_choices = get_search_kinds() From 9b4383c472c4409579e394e55d52778aebc972ac Mon Sep 17 00:00:00 2001 From: Theo Rozier Date: Fri, 2 Feb 2024 15:11:26 +0100 Subject: [PATCH 20/30] Shell argument is now required for show completion --- portablemc/cli/parse.py | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/portablemc/cli/parse.py b/portablemc/cli/parse.py index 01f7e4b7..2fc9aa34 100644 --- a/portablemc/cli/parse.py +++ b/portablemc/cli/parse.py @@ -195,10 +195,7 @@ def register_show_completion_arguments(parser: ArgumentParser) -> None: # The shell argument is only required if the shell cannot be determined. shell_choices = get_completion_shells() - shell_default = os.getenv("SHELL") - if shell_default is not None and shell_default not in shell_choices: - shell_default = None - shell_arg = parser.add_argument("--shell", required=shell_default is None, default=shell_default, choices=shell_choices, help=_("args.show.completion.shell")) + shell_arg = parser.add_argument("shell", choices=shell_choices, help=_("args.show.completion.shell")) for choice in shell_choices: add_completion(shell_arg, choice, _(f"args.show.completion.shell.comp.{choice}")) From be9d66a3d3272a286ba76aa8a68d4cfc812b5fcb Mon Sep 17 00:00:00 2001 From: Theo Rozier Date: Fri, 2 Feb 2024 15:22:01 +0100 Subject: [PATCH 21/30] Made a zsh completion generic over autoload or eval context. --- portablemc/cli/__init__.py | 4 +--- portablemc/cli/complete.py | 14 +++++++------- portablemc/cli/lang.py | 9 ++++----- portablemc/cli/parse.py | 2 +- 4 files changed, 13 insertions(+), 16 deletions(-) diff --git a/portablemc/cli/__init__.py b/portablemc/cli/__init__.py index 2a68567d..233481ee 100644 --- a/portablemc/cli/__init__.py +++ b/portablemc/cli/__init__.py @@ -545,9 +545,7 @@ def cmd_show_completion(ns: ShowCompletionNs): from .complete import gen_zsh_completion, gen_bash_completion if ns.shell == "zsh": - content = gen_zsh_completion(ns.parser, False) - elif ns.shell == "zsh-eval": - content = gen_zsh_completion(ns.parser, True) + content = gen_zsh_completion(ns.parser) elif ns.shell == "bash": content = gen_bash_completion(ns.parser) else: diff --git a/portablemc/cli/complete.py b/portablemc/cli/complete.py index 3618d51b..bc2bbdad 100644 --- a/portablemc/cli/complete.py +++ b/portablemc/cli/complete.py @@ -10,15 +10,15 @@ from typing import Dict, Tuple, cast -def gen_zsh_completion(parser: ArgumentParser, evaluated: bool) -> str: +def gen_zsh_completion(parser: ArgumentParser) -> str: buffer = StringIO() - if not evaluated: - buffer.write("#compdef portablemc\n\n") + buffer.write("#compdef portablemc\n\n") gen_zsh_parser_completion(parser, buffer, "_portablemc") - if not evaluated: - buffer.write("_portablemc\n") - else: - buffer.write("compdef _portablemc portablemc\n") + buffer.write("if [[ $zsh_eval_context[-1] == loadautofunc ]]; then\n") + buffer.write(" _portablemc\n") + buffer.write("else\n") + buffer.write(" compdef _portablemc portablemc\n") + buffer.write("fi\n") return buffer.getvalue() def gen_zsh_parser_completion(parser: ArgumentParser, buffer: StringIO, function: str): diff --git a/portablemc/cli/lang.py b/portablemc/cli/lang.py index 93ead0f6..e75eeb2e 100644 --- a/portablemc/cli/lang.py +++ b/portablemc/cli/lang.py @@ -146,20 +146,19 @@ def get(key: str, **kwargs) -> str: " but can be stored in '~/.local/share/bash-completion/completions' for user-specific commands.\n" " You can run the following commands to generate the file:\n\n" " $ mkdir -p ~/.local/share/bash-completion/completions\n" - " $ portablemc show completion --shell bash > ~/.local/share/bash-completion/completions/portablemc\n\n" + " $ portablemc show completion bash > ~/.local/share/bash-completion/completions/portablemc\n\n" " You can also dynamically evaluate the script, but it may slow your shell startup:\n\n" - " $ eval \"$(portablemc show completion --shell bash)\"\n\n" + " $ eval \"$(portablemc show completion bash)\"\n\n" " ZSH:\n\n" " Zsh completions are commonly stored in any directory listed in your '$fpath' variable.\n" " To use these completions, you must either add the generated script to one of those\n" " directories, or add your own to this list. Once you chose a '$fpath' directory:\n\n" - " $ portablemc show completion --shell zsh > your-dir/_portablemc\n\n" + " $ portablemc show completion zsh > your-dir/_portablemc\n\n" " You can also dynamically evaluate a script, but it may slow your shell startup:\n\n" - " $ eval \"$(portablemc show completion --shell zsh-eval)\"\n\n", + " $ eval \"$(portablemc show completion zsh)\"\n\n", "args.show.completion.shell": "The shell to generate completion script for (default to your current shell, required if not found).", "args.show.completion.shell.comp.bash": "Generate completion script for Bash.", "args.show.completion.shell.comp.zsh": "Generate completion script for Zsh.", - "args.show.completion.shell.comp.zsh-eval": "Generate completion script for Zsh to inline (eval).", # Common "echo": "{echo}", "cancelled": "Cancelled.", diff --git a/portablemc/cli/parse.py b/portablemc/cli/parse.py index 2fc9aa34..a301a4dc 100644 --- a/portablemc/cli/parse.py +++ b/portablemc/cli/parse.py @@ -220,7 +220,7 @@ def get_auth_services() -> List[str]: return ["microsoft", "yggdrasil"] def get_completion_shells() -> List[str]: - return ["bash", "zsh", "zsh-eval"] + return ["bash", "zsh"] def type_path(s: str) -> Path: From 0bb0a362285cde859fd6e4fe89416da728549968 Mon Sep 17 00:00:00 2001 From: Theo Rozier Date: Fri, 2 Feb 2024 15:49:17 +0100 Subject: [PATCH 22/30] Removed old unused lang --- portablemc/cli/lang.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/portablemc/cli/lang.py b/portablemc/cli/lang.py index e75eeb2e..5e67e725 100644 --- a/portablemc/cli/lang.py +++ b/portablemc/cli/lang.py @@ -127,10 +127,8 @@ def get(key: str, **kwargs) -> str: "args.start.server_port": "Set the server port (given with -s, --server, >= 1.6).", # Args login "args.login": "Login into your account and save the session.", - "args.login.microsoft": "Login using Microsoft account.", # Args logout "args.logout": "Logout and invalidate a session.", - "args.logout.microsoft": "Logout from a Microsoft account.", # Args show "args.show": "Show, debug and generate data unrelated to the game.", "args.show.about": "Display authors, version and license of PortableMC.", From ff18255f6320887af0287e6817410f9cf717a5b7 Mon Sep 17 00:00:00 2001 From: Theo Rozier Date: Fri, 2 Feb 2024 15:54:31 +0100 Subject: [PATCH 23/30] Added check for successful gen_completion functions --- test/test_cli_misc.py | 20 ++++++++++++-------- 1 file changed, 12 insertions(+), 8 deletions(-) diff --git a/test/test_cli_misc.py b/test/test_cli_misc.py index 8d9cbd13..ad4c7d7e 100644 --- a/test/test_cli_misc.py +++ b/test/test_cli_misc.py @@ -35,14 +35,6 @@ def test_anonymise_email(): assert anonymize_email("foo.bar@baz.com") == "f*****r@b*z.com" -def test_register_arguments(): - - from portablemc.cli import register_arguments - - # Ensure that the arguments registering successfully works. - register_arguments() - - def test_library_specifier_filter(): from portablemc.cli.util import LibrarySpecifierFilter @@ -78,3 +70,15 @@ def test_library_specifier_filter(): assert not LibrarySpecifierFilter("baz", "0.2.0", "natives-windows-x86").matches(spec_classified) assert not LibrarySpecifierFilter("baz", "0.1.0", "windows").matches(spec) assert not LibrarySpecifierFilter("baz", "0.1.0", "windows").matches(spec_classified) + + +def test_parser_and_completion(): + + from portablemc.cli.complete import gen_zsh_completion, gen_bash_completion + from portablemc.cli import register_arguments + + # Ensure that the arguments registering successfully works. + args = register_arguments() + # Just check that it doesn't crash. + gen_zsh_completion(args) + gen_bash_completion(args) From 9f775e77bacd62cc170222425a99c6edd80884e9 Mon Sep 17 00:00:00 2001 From: Theo Rozier Date: Fri, 2 Feb 2024 22:16:44 +0100 Subject: [PATCH 24/30] Updated API doc version --- README.md | 2 +- doc/API.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 9d488adb..4acb566c 100644 --- a/README.md +++ b/README.md @@ -28,7 +28,7 @@ This launcher is compatible with the standard Minecraft directories. - [Setup environment](#setup-environment) - [Contributors](#contributors) - [Sponsors](#sponsors) -- [API Documentation (v4) ⇗](https://github.com/mindstorm38/portablemc/blob/main/doc/API.md) +- [API Documentation (v4.2) ⇗](https://github.com/mindstorm38/portablemc/blob/main/doc/API.md) ## Installation This launcher can be installed using `pip`. On some linux distribution you might have to diff --git a/doc/API.md b/doc/API.md index d9227f57..10a9d1a4 100644 --- a/doc/API.md +++ b/doc/API.md @@ -3,7 +3,7 @@ This page documents the public API of the launcher. This launcher library provides high flexibility for launching Minecraft in many environments. -Documented version: `4.0.0`. +Documented version: `4.2.0`. ## Table of contents - [File structure](#file-structure) From 943cd8f3853d794c1d0a470170e52aa14ce6ce13 Mon Sep 17 00:00:00 2001 From: Theo Rozier Date: Sat, 3 Feb 2024 11:25:14 +0100 Subject: [PATCH 25/30] Removed mention of GitHub for contributors in copyright owners. --- portablemc/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/portablemc/__init__.py b/portablemc/__init__.py index 7606097e..019d620c 100644 --- a/portablemc/__init__.py +++ b/portablemc/__init__.py @@ -8,6 +8,6 @@ LAUNCHER_NAME = "portablemc" LAUNCHER_VERSION = "4.2.0" -LAUNCHER_AUTHORS = ["Théo Rozier ", "GitHub Contributors"] +LAUNCHER_AUTHORS = ["Théo Rozier ", "Contributors"] LAUNCHER_COPYRIGHT = "PortableMC Copyright (C) 2021-2024 Théo Rozier" LAUNCHER_URL = "https://github.com/mindstorm38/portablemc" From bc0e7d8f23778f5d7ec38ae579eb292a0fd574bc Mon Sep 17 00:00:00 2001 From: Theo Rozier Date: Sat, 3 Feb 2024 11:47:04 +0100 Subject: [PATCH 26/30] README mention of arch linux AUR package and show completion command. --- README.md | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/README.md b/README.md index 4acb566c..bd96edda 100644 --- a/README.md +++ b/README.md @@ -11,6 +11,8 @@ This launcher is compatible with the standard Minecraft directories. ## Table of contents - [Installation](#installation) + - [With pip](#with-pip) + - [With Arch Linux](#with-arch-linux) - [Commands](#commands) - [Start Minecraft](#start-minecraft) - [Authentication](#authentication) @@ -22,6 +24,7 @@ This launcher is compatible with the standard Minecraft directories. - [Miscellaneous](#miscellaneous) - [Search for versions](#search-for-versions) - [Authentication sessions](#authentication-sessions) + - [Shell completion](#shell-completion) - [Offline support](#offline-support) - [Certifi support](#certifi-support) - [Contribute](#contribute) @@ -31,6 +34,9 @@ This launcher is compatible with the standard Minecraft directories. - [API Documentation (v4.2) ⇗](https://github.com/mindstorm38/portablemc/blob/main/doc/API.md) ## Installation + +### With pip + This launcher can be installed using `pip`. On some linux distribution you might have to use `pip3` instead of `pip` in order to run it on Python 3. You can also use `python -m pip` if the `pip` command is not in the path and the python executable is. @@ -49,6 +55,12 @@ environment variable. On Windows you have to search for a directory at `%appdata%/Python/Python3X/Scripts` and add it to the user's environment variable `Path`. On UNIX systems it's `~/.local/bin`. +### With Arch Linux + +For Arch Linus users, the package is available as `portablemc` in the +[AUR](https://aur.archlinux.org/packages/portablemc). +*This is currently maintained by Maks Jopek, Thanks!* + ## Commands Arguments are split between multiple commands. For example `portablemc [global-args] [args]`. @@ -257,6 +269,16 @@ you need to log into an old Mojang account (being phased out by Mojang). **Your password is not saved!** Only tokens are saved *(the official launcher also does that)* in the file `portablemc_auth.json` in the working directory. +### Shell completion +The launcher can generate shell completions scripts for Bash and Zsh shells through the +`portablemc show completion {bash,zsh}` command. If you need precise explanation on how +to install the completions, read this command's help message. **This command needs to be +re-run for every new version of the launcher**, you're not affected if you directly eval +the result. + +*Note that Zsh completion scripts can be used both as an auto-load script and as +evaluated one.* + ## Offline support This launcher can be used without internet access under certain conditions. Launching versions is possible if all required resources are locally installed, it is also possible From 90b49ec22bee67a037bbb65b1f17489381b8d180 Mon Sep 17 00:00:00 2001 From: Theo Rozier Date: Sat, 3 Feb 2024 11:55:35 +0100 Subject: [PATCH 27/30] Note about certifi optional dependency. --- README.md | 22 ++++++++++++++-------- 1 file changed, 14 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index bd96edda..4efc344f 100644 --- a/README.md +++ b/README.md @@ -42,22 +42,26 @@ use `pip3` instead of `pip` in order to run it on Python 3. You can also use `python -m pip` if the `pip` command is not in the path and the python executable is. ```sh -pip install --user portablemc +pip install --user portablemc[certifi] ``` -It's recommended to keep `--user` because this installs the launcher for your -current user only, it is implicit if you are not an administrator and if you are, it -allows not to modify other users' installations. - After that, you can try to show the launcher help message using `portablemc` in your terminal. If it fails, you should check that the scripts directory is in your user path environment variable. On Windows you have to search for a directory at `%appdata%/Python/Python3X/Scripts` and add it to the user's environment variable `Path`. On UNIX systems it's `~/.local/bin`. +You can opt-out from the `certifi` optional feature if you don't want to depend on it, +learn more in the [Certifi support](#certifi-support) section. + +> [!TIP] +> It's recommended to keep `--user` because this installs the launcher for your current +> user only and does not pollute other's environments, it is implicit if you are not an +> administrator and if you are, it allows not to modify other users' installations. + ### With Arch Linux -For Arch Linus users, the package is available as `portablemc` in the +For Arch Linux users, the package is available as `portablemc` in the [AUR](https://aur.archlinux.org/packages/portablemc). *This is currently maintained by Maks Jopek, Thanks!* @@ -231,8 +235,10 @@ these are discarded in the bin directory, for example These arguments can be used together to fix various issues (e.g. wrong libc being linked by the LWJGL-provided natives). -*Note that these arguments are compatible with, and executed after the `--lwjgl` argument. -You must however ensure that excluded lib and included binaries are compatible.* +> [!NOTE] +> Note that these arguments are compatible with, and executed after the `--lwjgl` +> argument. You must however ensure that excluded lib and included binaries are +> compatible. #### Miscellaneous With `--dry`, the start command does not start the game, but simply installs it. From b7678fca4d13cb08399a89ebfe7671cca173994c Mon Sep 17 00:00:00 2001 From: Theo Rozier Date: Fri, 9 Feb 2024 20:40:29 +0100 Subject: [PATCH 28/30] README update before release --- README.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 4efc344f..e9aedf99 100644 --- a/README.md +++ b/README.md @@ -3,7 +3,7 @@ A fast, reliable and cross-platform command-line Minecraft launcher and API for Including fast and easy installation of common mod loaders such as Fabric, Forge, NeoForge and Quilt. This launcher is compatible with the standard Minecraft directories. -[![PyPI - Version](https://img.shields.io/pypi/v/portablemc?label=PyPI%20version&style=flat-square)  ![PyPI - Downloads](https://img.shields.io/pypi/dm/portablemc?label=PyPI%20downloads&style=flat-square)](https://pypi.org/project/portablemc/) +[![PyPI - Version](https://img.shields.io/pypi/v/portablemc?label=PyPI%20version&style=flat-square)![PyPI - Downloads](https://img.shields.io/pypi/dm/portablemc?label=PyPI%20downloads&style=flat-square)](https://pypi.org/project/portablemc/) ![illustration](https://github.com/mindstorm38/portablemc/blob/main/doc/illustration.png) @@ -63,6 +63,7 @@ learn more in the [Certifi support](#certifi-support) section. For Arch Linux users, the package is available as `portablemc` in the [AUR](https://aur.archlinux.org/packages/portablemc). + *This is currently maintained by Maks Jopek, Thanks!* ## Commands From 0b371712959b22938202e431d3724254255b7cad Mon Sep 17 00:00:00 2001 From: Theo Rozier Date: Fri, 9 Feb 2024 21:45:58 +0100 Subject: [PATCH 29/30] Version 4.2.1 immediate bump because I'm dumb --- portablemc/__init__.py | 2 +- pyproject.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/portablemc/__init__.py b/portablemc/__init__.py index 019d620c..84cd4472 100644 --- a/portablemc/__init__.py +++ b/portablemc/__init__.py @@ -7,7 +7,7 @@ """ LAUNCHER_NAME = "portablemc" -LAUNCHER_VERSION = "4.2.0" +LAUNCHER_VERSION = "4.2.1" LAUNCHER_AUTHORS = ["Théo Rozier ", "Contributors"] LAUNCHER_COPYRIGHT = "PortableMC Copyright (C) 2021-2024 Théo Rozier" LAUNCHER_URL = "https://github.com/mindstorm38/portablemc" diff --git a/pyproject.toml b/pyproject.toml index bd83bdca..a1184aa1 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "poetry.core.masonry.api" [tool.poetry] name = "portablemc" -version = "4.2.0" +version = "4.2.1" description = "PortableMC is a module that provides both an API for development of your custom launcher and an executable script to run PortableMC CLI." authors = ["Théo Rozier "] license = "GPL-3.0-only" From c11507cb7334714517b0f196865458b5615bcd1a Mon Sep 17 00:00:00 2001 From: Theo Rozier Date: Fri, 9 Feb 2024 21:46:14 +0100 Subject: [PATCH 30/30] Typo in doc --- doc/API.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/doc/API.md b/doc/API.md index 10a9d1a4..d2e2ad33 100644 --- a/doc/API.md +++ b/doc/API.md @@ -3,7 +3,7 @@ This page documents the public API of the launcher. This launcher library provides high flexibility for launching Minecraft in many environments. -Documented version: `4.2.0`. +Documented version: `4.2`. ## Table of contents - [File structure](#file-structure)