diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index aaeaded2..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 @@ -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 diff --git a/README.md b/README.md index 9d5f280a..e9aedf99 100644 --- a/README.md +++ b/README.md @@ -1,16 +1,18 @@ # 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) +[![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.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) + - [With pip](#with-pip) + - [With Arch Linux](#with-arch-linux) - [Commands](#commands) - [Start Minecraft](#start-minecraft) - [Authentication](#authentication) @@ -22,33 +24,48 @@ It also includes fast installation of common mod loaders such as Fabric, Forge a - [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) - [Setup environment](#setup-environment) - [Contributors](#contributors) - [Sponsors](#sponsors) -- [API Documentation (v4) ⇗](doc/API.md) +- [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. ```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 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 Arguments are split between multiple commands. For example `portablemc [global-args] [args]`. @@ -219,8 +236,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. @@ -257,6 +276,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 diff --git a/doc/API.md b/doc/API.md index d9227f57..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.0.0`. +Documented version: `4.2`. ## Table of contents - [File structure](#file-structure) diff --git a/poetry.lock b/poetry.lock index 2015dfda..fae0eaac 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1,4 +1,15 @@ -# 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 = "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" @@ -13,71 +24,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] @@ -85,38 +88,18 @@ 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] 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" @@ -130,51 +113,47 @@ 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]] 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"] [[package]] name = "pytest" -version = "7.4.0" +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.0-py3-none-any.whl", hash = "sha256:78bf16451a2eb8c7a2ea98e32dc119fd2aa758f1d5d66dbf0a59d69a3969df32"}, - {file = "pytest-7.4.0.tar.gz", hash = "sha256:b4bf8c45bd59934ed84001ad51e11b4ee40d40a1229d2c79f9c592b0a3f6bd8a"}, + {file = "pytest-8.0.0-py3-none-any.whl", hash = "sha256:50fb9cbe836c3f20f0dfa99c565201fb75dc54c8d76373cd1bde06b06657bdb6"}, + {file = "pytest-8.0.0.tar.gz", hash = "sha256:249b1b0864530ba251b7438274c4d251c58d868edaaec8762893ad4a0d71c36c"}, ] [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" +pluggy = ">=1.3.0,<2.0" tomli = {version = ">=1.0.0", markers = "python_version < \"3.11\""} [package.extras] @@ -202,33 +181,10 @@ 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] +certifi = ["certifi"] [metadata] lock-version = "2.0" -python-versions = ">=3.7" -content-hash = "2cab62b3493ef065b4c886da47f6e0bd2e7cf1dc137fb88cc0ed08729be993d5" +python-versions = ">=3.8" +content-hash = "35b4199432e6a29f6e16993b314c0bdcbed39e3496e95d8f9f416beffe00f12c" diff --git a/portablemc/__init__.py b/portablemc/__init__.py index 9d745c4f..84cd4472 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.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/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/auth.py b/portablemc/auth.py index 197b4fb9..6bcaae89 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): @@ -231,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, @@ -243,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 @@ -250,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() diff --git a/portablemc/cli/__init__.py b/portablemc/cli/__init__.py index 71c7451d..233481ee 100644 --- a/portablemc/cli/__init__.py +++ b/portablemc/cli/__init__.py @@ -12,7 +12,8 @@ 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, ShowCompletionNs + 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 +71,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,11 +126,8 @@ def get_command_handlers() -> CommandTree: "about": cmd_show_about, "auth": cmd_show_auth, "lang": cmd_show_lang, + "completion": cmd_show_completion, }, - # "addon": { - # "list": cmd_addon_list, - # "show": cmd_addon_show - # } } @@ -314,7 +313,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) @@ -326,7 +325,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: @@ -376,7 +375,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() @@ -468,7 +466,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 +540,29 @@ 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 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 + + 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, None otherwise. This function handles task printing and all exceptions are caught internally. """ + service = ns.auth_service + session_class = { "microsoft": MicrosoftAuthSession, "yggdrasil": YggdrasilAuthSession, @@ -556,6 +570,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 +609,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 @@ -611,7 +628,7 @@ def prompt_authenticate(ns: RootNs, email: str, caching: bool, service: str, ano 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") @@ -621,7 +638,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 @@ -629,103 +646,117 @@ def prompt_microsoft_authenticate(ns: RootNs, email: str) -> Optional[MicrosoftA import urllib.parse import webbrowser - server_port = 12782 + nonce = uuid4().hex 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" + 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" + })) + + auth_query = None + + if not ns.auth_no_browser: - nonce = uuid4().hex + class AuthServer(HTTPServer): - 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 + def __init__(self): + super().__init__(("127.0.0.1", 0), RequestHandler) + self.timeout = 0.5 + self.ms_auth_query: Optional[str] = None + + class RequestHandler(BaseHTTPRequestHandler): - 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])) + 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 do_GET(self): + parsed = urllib.parse.urlparse(self.path) + if parsed.path in ("", "/"): + cast(AuthServer, self.server).ms_auth_query = parsed.query + self.send_response(200) 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_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: + + # 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 - ns.out.task("..", "auth.microsoft.opening_browser_and_listening") + auth_query = server.ms_auth_query - with AuthServer() as server: - try: - while not server.ms_auth_done: - server.handle_request() - except KeyboardInterrupt: - pass + # 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: - if server.ms_auth_code is None or server.ms_auth_id_token is None: + ns.out.task("INFO", "auth.microsoft.no_browser_fallback") 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) + ns.out.print(gen_auth_url("") + "\n") + + 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: + 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/complete.py b/portablemc/cli/complete.py new file mode 100644 index 00000000..bc2bbdad --- /dev/null +++ b/portablemc/cli/complete.py @@ -0,0 +1,251 @@ +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 gen_zsh_completion(parser: ArgumentParser) -> str: + buffer = StringIO() + buffer.write("#compdef portablemc\n\n") + gen_zsh_parser_completion(parser, buffer, "_portablemc") + 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): + + # 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(" 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": :->arg_{action.dest}" + completions[f"arg_{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, (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, (cmd_description, cmd_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 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 + # + # 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{\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): + # 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. + # 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, _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)) + 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): + + 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 action.choices is not None: + reply = f'$(compgen -W "{" ".join(action.choices)}" -- "$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}") diff --git a/portablemc/cli/lang.py b/portablemc/cli/lang.py index 43df754e..5e67e725 100644 --- a/portablemc/cli/lang.py +++ b/portablemc/cli/lang.py @@ -33,35 +33,63 @@ 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 " - "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 " "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.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.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": "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.", + "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": "Start the game.", "args.start.version": "Version identifier (default to release): {formats}.", "args.start.version.standard": "release|snapshot|", "args.start.version.fabric": "fabric:[[:]]", "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).", @@ -91,26 +119,44 @@ 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).", - "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.", # Args logout "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 addon - # "args.addon": "Addons management subcommands.", - # "args.addon.list": "List addons.", - # "args.addon.show": "Show an addon details.", + "args.show.completion": "Print a shell completion script.", + "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 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 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 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 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.", # Common "echo": "{echo}", "cancelled": "Cancelled.", @@ -139,15 +185,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}.", @@ -220,9 +257,11 @@ 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 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": "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.", diff --git a/portablemc/cli/parse.py b/portablemc/cli/parse.py index bb8fcd92..a301a4dc 100644 --- a/portablemc/cli/parse.py +++ b/portablemc/cli/parse.py @@ -1,6 +1,8 @@ -from argparse import ArgumentParser, HelpFormatter, ArgumentTypeError, SUPPRESS +from argparse import ArgumentParser, Action, SUPPRESS, \ + HelpFormatter, RawDescriptionHelpFormatter, ArgumentTypeError from pathlib import Path import sys +import os from portablemc.standard import Context, VersionManifest from portablemc.auth import AuthDatabase @@ -9,8 +11,7 @@ from .output import Output from .lang import get as _ -from typing import Optional, Type, Tuple, List - +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. @@ -22,6 +23,7 @@ class RootNs: out_kind: str verbose: int # Initialized by main function after argument parsing. + parser: ArgumentParser out: Output context: Context version_manifest: VersionManifest @@ -32,13 +34,17 @@ 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 demo: bool resolution: Optional[Tuple[int, int]] - jvm: Optional[str] + jvm: Optional[Path] jvm_args: Optional[str] no_fix: bool fabric_prefix: str @@ -47,25 +53,26 @@ class StartNs(RootNs): neoforge_prefix: str lwjgl: Optional[str] exclude_lib: Optional[List[LibrarySpecifierFilter]] - include_bin: Optional[List[str]] + include_bin: Optional[List[Path]] 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 +class ShowCompletionNs(RootNs): + shell: str + def register_common_help(parser: ArgumentParser) -> None: parser.formatter_class = new_help_formatter_class(40) @@ -73,18 +80,33 @@ 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) + + 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) + + 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")) + return parser @@ -94,23 +116,31 @@ 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: + + parser.description = _("args.search._") register_common_help(parser) - parser.add_argument("-k", "--kind", help=_("args.search.kind"), default="mojang", choices=get_search_kinds()) - parser.add_argument("input", nargs="?") + 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) - 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") @@ -119,28 +149,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) - 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("version", nargs="?", default="release", help=_("args.start.version", formats=", ".join(map(lambda s: _(f"args.start.version.{s}"), ("standard", "fabric", "quilt", "forge", "neoforge"))))) + 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"))))) + 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: @@ -150,19 +185,25 @@ 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_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 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. + shell_choices = get_completion_shells() + 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}")) 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) @@ -172,18 +213,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 type_path(s: str) -> Path: + return Path(s) -def resolution_from_str(s: str) -> Tuple[int, int]: +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 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 get_completions(action: Action) -> Dict[str, str]: + return getattr(action, "_pmc_completions", {}) 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/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: diff --git a/pyproject.toml b/pyproject.toml index 9522ebfb..a1184aa1 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.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" @@ -20,7 +20,10 @@ 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 } [tool.poetry.group.test.dependencies] pytest = "*" @@ -29,3 +32,6 @@ coverage = "*" [tool.poetry.scripts] portablemc = "portablemc.cli:main" + +[tool.poetry.extras] +certifi = ["certifi"] diff --git a/test/test_cli_misc.py b/test/test_cli_misc.py index 43128992..ad4c7d7e 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" @@ -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)