├── .github ├── ISSUE_TEMPLATE │ ├── bug_report.md │ ├── custom.md │ └── feature_request.md ├── pull_request_template.md └── workflows │ ├── lint.yml │ ├── pytest.yml │ └── python-publish.yml ├── .gitignore ├── .lgtm.yml ├── .markdownlint.yml ├── CHANGELOG.md ├── LICENSE ├── NEWS.md ├── README.md ├── minecraft_mod_manager ├── __init__.py ├── __main__.py ├── adapters │ ├── __init__.py │ ├── repo_impl.py │ └── repo_impl_test.py ├── app │ ├── __init__.py │ ├── configure │ │ ├── __init__.py │ │ ├── configure.py │ │ ├── configure_repo.py │ │ └── configure_test.py │ ├── download │ │ ├── __init__.py │ │ ├── download.py │ │ ├── download_repo.py │ │ └── download_test.py │ ├── install │ │ ├── __init__.py │ │ ├── install.py │ │ ├── install_repo.py │ │ └── install_test.py │ ├── show │ │ ├── __init__.py │ │ ├── show.py │ │ ├── show_repo.py │ │ └── show_test.py │ └── update │ │ ├── __init__.py │ │ ├── update.py │ │ ├── update_repo.py │ │ └── update_test.py ├── config.py ├── core │ ├── __init__.py │ ├── entities │ │ ├── __init__.py │ │ ├── actions.py │ │ ├── mod.py │ │ ├── mod_loaders.py │ │ ├── sites.py │ │ └── version_info.py │ ├── errors │ │ ├── __init__.py │ │ ├── download_failed.py │ │ ├── mod_already_exists.py │ │ ├── mod_file_invalid.py │ │ └── mod_not_found_exception.py │ └── utils │ │ ├── __init__.py │ │ ├── latest_version_finder.py │ │ └── latest_version_finder_test.py ├── gateways │ ├── __init__.py │ ├── api │ │ ├── __init__.py │ │ ├── api.py │ │ ├── curse_api.py │ │ ├── curse_api_test.py │ │ ├── mod_finder.py │ │ ├── mod_finder_test.py │ │ ├── modrinth_api.py │ │ ├── modrinth_api_test.py │ │ ├── testdata │ │ │ ├── curse_api │ │ │ │ ├── files_carpet.json │ │ │ │ ├── mod_info.json │ │ │ │ └── search_carpet.json │ │ │ └── modrinth_api │ │ │ │ ├── mod_info.json │ │ │ │ ├── search_fabric-api.json │ │ │ │ ├── version.json │ │ │ │ ├── versions-without-files.json │ │ │ │ └── versions_fabric-api.json │ │ └── word_splitter_api.py │ ├── arg_parser.py │ ├── arg_parser_test.py │ ├── http.py │ ├── http_test.py │ ├── jar_parser.py │ ├── jar_parser_test.py │ ├── sqlite.py │ ├── sqlite_test.py │ ├── sqlite_upgrader.py │ ├── sqlite_upgrader_test.py │ └── testdata │ │ ├── mods │ │ ├── README.md │ │ ├── fabric-invalid-character.jar │ │ ├── fabric-invalid-control-character.jar │ │ ├── fabric-valid.jar │ │ ├── forge-valid.jar │ │ ├── invalid.jar │ │ └── toml-inline-comment.jar │ │ └── unbalanced-quotes.toml └── utils │ ├── __init__.py │ └── log_colors.py ├── pytest.ini ├── renovate.json ├── requirements.txt ├── setup.cfg ├── setup.py └── tests ├── README.md ├── __init__.py ├── install_test.py ├── update_test.py └── util ├── __init__.py └── helper.py /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | title: '' 5 | labels: bug, triage 6 | assignees: '' 7 | 8 | --- 9 | 10 | ### Describe the bug 11 | A clear and concise description of what the bug is. 12 | 13 | ### Steps to reproduce 14 | Steps to reproduce the behavior: 15 | 1. Ran `mcman ...` 16 | 2. Then ran `mcman ...` 17 | 18 | ### Expected Behavior 19 | A clear and concise description of what you expected to happen. 20 | 21 | ### Info 22 | - OS: 23 | - Python --version: 24 | - minecraft-mod-manager --version: 25 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/custom.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Custom 3 | about: Custom issue, request, or question 4 | title: '' 5 | labels: triage 6 | assignees: '' 7 | 8 | --- 9 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | title: '' 5 | labels: enhancement, triage 6 | assignees: '' 7 | 8 | --- 9 | 10 | ### Is your feature request related to a problem? Please describe 11 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] 12 | 13 | ### Describe the solution you'd like 14 | A clear and concise description of what you want to happen. 15 | 16 | ### Describe alternatives you've considered 17 | A clear and concise description of any alternative solutions or features you've considered. 18 | 19 | ### Additional context 20 | Add any other context or screenshots about the feature request here. 21 | -------------------------------------------------------------------------------- /.github/pull_request_template.md: -------------------------------------------------------------------------------- 1 | ### What changed? 2 | - 3 | 4 | ### Testing 5 | - [ ] Added unit tests 6 | - [ ] Added integration tests 7 | - [ ] ...Your own tests... 8 | 9 | ### Checklist 10 | - [ ] I have reviewed my own code 11 | 12 | ### Related Issues 13 | Fixes 14 | -------------------------------------------------------------------------------- /.github/workflows/lint.yml: -------------------------------------------------------------------------------- 1 | # This workflow checks for linting errors 2 | 3 | name: lint 4 | 5 | on: 6 | push: 7 | branches: [main] 8 | pull_request: 9 | branches: [main] 10 | 11 | jobs: 12 | build: 13 | runs-on: ubuntu-latest 14 | 15 | name: black & flake8 & markdown 16 | 17 | steps: 18 | - uses: actions/checkout@v3 19 | - name: Set up Python 3.9 20 | uses: actions/setup-python@v4 21 | with: 22 | python-version: 3.9 23 | - name: Install dependencies 24 | run: | 25 | python -m pip install --upgrade pip 26 | python -m pip install flake8 black 27 | - name: Lint with black 28 | run: black --line-length 119 --check . 29 | - name: Lint with flake8 30 | run: | 31 | # stop the build if there are Python syntax errors or undefined names 32 | flake8 . --count --select=E9,F63,F7,F82 --max-line-length=119 --max-complexity=10 --show-source --statistics 33 | - name: Markdown lint 34 | uses: DavidAnson/markdownlint-cli2-action@v6 35 | with: 36 | globs: '**/*.md' 37 | -------------------------------------------------------------------------------- /.github/workflows/pytest.yml: -------------------------------------------------------------------------------- 1 | # This workflow will install Python dependencies and run tests with a variety of Python versions 2 | 3 | name: Tests 4 | 5 | on: 6 | push: 7 | branches: [main] 8 | pull_request: 9 | branches: [main] 10 | 11 | jobs: 12 | build: 13 | runs-on: ubuntu-latest 14 | strategy: 15 | matrix: 16 | python-version: ['3.8', '3.9', '3.10'] 17 | 18 | name: Python ${{ matrix.python-version }} 19 | 20 | steps: 21 | - uses: actions/checkout@v3 22 | - name: Set up Python ${{ matrix.python-version }} 23 | uses: actions/setup-python@v4 24 | with: 25 | python-version: ${{ matrix.python-version }} 26 | - name: Install dependencies 27 | run: | 28 | python -m pip install --upgrade pip 29 | python -m pip install pytest mockito 30 | if [ -f requirements.txt ]; then pip install -r requirements.txt; fi 31 | - name: Test with pytest 32 | run: | 33 | pytest 34 | -------------------------------------------------------------------------------- /.github/workflows/python-publish.yml: -------------------------------------------------------------------------------- 1 | # This workflow will upload a Python Package using Twine when a release is created 2 | # For more information see: https://help.github.com/en/actions/language-and-framework-guides/using-python-with-github-actions#publishing-to-package-registries 3 | 4 | name: Publish Python Package 5 | 6 | on: 7 | push: 8 | tags: 9 | - '*' 10 | 11 | jobs: 12 | deploy: 13 | runs-on: ubuntu-latest 14 | 15 | steps: 16 | - name: Checkout repository 17 | uses: actions/checkout@v3 18 | - name: Set up Python 19 | uses: actions/setup-python@v4 20 | with: 21 | python-version: '3.x' 22 | - name: Install dependencies 23 | run: | 24 | python -m pip install --upgrade pip 25 | pip install setuptools setuptools_scm wheel twine 26 | - name: Build and publish 27 | env: 28 | TWINE_USERNAME: __token__ 29 | TWINE_PASSWORD: ${{ secrets.PYPI_PASSWORD }} 30 | run: | 31 | python setup.py sdist bdist_wheel 32 | twine upload dist/* 33 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .vscode 2 | **/__pycache__ 3 | temp 4 | .coverage 5 | -------------------------------------------------------------------------------- /.lgtm.yml: -------------------------------------------------------------------------------- 1 | extraction: 2 | python: 3 | python_setup: 4 | version: 3 5 | 6 | path_classifiers: 7 | test: 8 | - exclude: / 9 | - exclude: tests/util 10 | 11 | queries: 12 | - include: '*' 13 | - exclude: 'py/uninitialized-local-variable' 14 | -------------------------------------------------------------------------------- /.markdownlint.yml: -------------------------------------------------------------------------------- 1 | default: true 2 | MD013: 3 | line_length: 119 4 | heading_line_length: 80 5 | code_block_line_length: 119 6 | code_blocks: true 7 | tables: false 8 | headings: true 9 | headers: true 10 | strict: false 11 | stern: false 12 | MD024: 13 | allow_different_nesting: true 14 | siblings_only: false 15 | MD026: 16 | punctuation: .,;:。,;:! 17 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | All notable changes to this project will be documented in this file 4 | 5 | The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), 6 | and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). 7 | 8 | ## `1.4.2` - 2022-08-27: Download correct modloader 9 | 10 | ### Fixed 11 | 12 | - Downloading correct mod loader. 13 | When downloading from Modrinth, mod loaders like quilt and bukkit were removed instead of registered as unknown. [#202](https://github.com/Senth/minecraft-mod-manager/issues/202) 14 | 15 | ## `1.4.1` - 2022-08-02: Fix modrinth bug 16 | 17 | ### Fixed 18 | 19 | - Modrinth bug when searching for some mods [#196](https://github.com/Senth/minecraft-mod-manager/issues/196) 20 | - Correct link to GitHub Project page [#191](https://github.com/Senth/minecraft-mod-manager/issues/191) 21 | 22 | ## `1.4.0` - 2022-06-14: Disabling CurseForge 23 | 24 | ### Removed 25 | 26 | - CurseForge support, will be added again in v2.0. In the meantime, checkout [Ferium](https://github.com/gorilla-devs/ferium) 27 | for another minecraft mod manager. 28 | 29 | ## [1.3.0] - 2022-05-07: Installation Improvements 30 | 31 | ### Added 32 | 33 | - Dependencies are now installed automatically 🎉 34 | - `--no-color` option to disable color output [#141](https://github.com/Senth/minecraft-mod-manager/issues/141) 35 | - Runtime cache to save on requests to Curse/Modrinth APIs 36 | 37 | ### Changed 38 | 39 | - `update` now skips downloading files if we already have the latest version [#139](https://github.com/Senth/minecraft-mod-manager/issues/139) 40 | - Improved the searching algorithm where the search term `entityculling` would previously return 0 results [#56](https://github.com/Senth/minecraft-mod-manager/issues/56) 41 | - Now uses tealprint library which will automatically disable color and unicode 42 | when trying to output Unicode characters [#141](https://github.com/Senth/minecraft-mod-manager/issues/141) 43 | 44 | ### Fixed 45 | 46 | - Crash when loading forge mods if they contained inline comments [#146](https://github.com/Senth/minecraft-mod-manager/issues/146) 47 | - Crash if a mod from Modrith was missing file links [#145](https://github.com/Senth/minecraft-mod-manager/issues/145) 48 | - Colored output continued to next messages [#141](https://github.com/Senth/minecraft-mod-manager/issues/141) 49 | - Handles Ctrl-C by stopping the program without printing a stack trace. 50 | It can still break the program in an invalid state [#140](https://github.com/Senth/minecraft-mod-manager/issues/140) 51 | - Now retries to fetch/download file 5 times before skipping [#169](https://github.com/Senth/minecraft-mod-manager/issues/169) 52 | - Now identifies installed mods correctly by slug as well [#149](https://github.com/Senth/minecraft-mod-manager/issues/149) 53 | - Can now search by slug on Modrinth [#135](https://github.com/Senth/minecraft-mod-manager/issues/135). 54 | When searching on Modrinth include on their site modrinth.com/mods, searching by slug can return an empty result, 55 | so we now get the mod directly by slug. 56 | - Fixed `--pretend` not printing error and actually showing a version number the mod would upgrade to [#93](https://github.com/Senth/minecraft-mod-manager/issues/93) 57 | 58 | ## [1.2.7] - 2022-03-06 59 | 60 | ### Fixed 61 | 62 | - Add official support for python 3.10 when installing with PIP [#160](https://github.com/Senth/minecraft-mod-manager/issues/160) 63 | - Modrinth blocking user agent. Now uses a random latest user agent, let's see if this fixed the problem [#150](https://github.com/Senth/minecraft-mod-manager/issues/150) 64 | - Doesn't save corrupt files that were downloaded, instead it keeps the old mod when updating [#150](https://github.com/Senth/minecraft-mod-manager/issues/150) 65 | 66 | ### Changes 67 | 68 | - Remove **bold** from `--version` [#153](https://github.com/Senth/minecraft-mod-manager/issues/153) 69 | - Use random latest user agent for both Modrinth [#150](https://github.com/Senth/minecraft-mod-manager/issues/150) and CurseForge 70 | - Now displays an error if there was any issue downloading the mod file 71 | 72 | ## [1.2.6] - 2022-02-21 73 | 74 | ### Fixed 75 | 76 | - Calling Modrinth API resulted in crash due to old User Agent [#150](https://github.com/Senth/minecraft-mod-manager/issues/150) 77 | 78 | ## [1.2.5] - 2021-10-31 79 | 80 | ### Fixed 81 | 82 | - Can load forge mods with invalid TOML syntax [#131](https://github.com/Senth/minecraft-mod-manager/issues/131) 83 | 84 | ## [1.2.4] - 2021-06-17 85 | 86 | ### Fixed 87 | 88 | - Installing via slug now works correctly (saves the mod correctly in db) [#32](https://github.com/Senth/minecraft-mod-manager/issues/32) 89 | 90 | ## [1.2.3] - 2021-06-16 91 | 92 | ### Fixed 93 | 94 | - Don't remove mods that are up-to-date [#81](https://github.com/Senth/minecraft-mod-manager/issues/81) 95 | 96 | ## [1.2.2] - 2021-06-15 97 | 98 | ### Fixed 99 | 100 | - Don't use strict JSON parsing from API response [#79](https://github.com/Senth/minecraft-mod-manager/issues/79) 101 | 102 | ## [1.2.1] - 2021-06-14 103 | 104 | ### Fixed 105 | 106 | - Don't remove mods when running with `--pretend` [#77](https://github.com/Senth/minecraft-mod-manager/issues/77) 107 | 108 | ## [1.2.0] - 2021-06-14 109 | 110 | ### Added 111 | 112 | - Shorthand command for `minecraft-mod-manager`; you can now use `mcman` or `mmm` instead 🙂 [#57](https://github.com/Senth/minecraft-mod-manager/issues/57) 113 | 114 | ### Changed 115 | 116 | - Can now download from multiple sites [#27](https://github.com/Senth/minecraft-mod-manager/issues/27) and [#68](https://github.com/Senth/minecraft-mod-manager/issues/68) 117 | 118 | - Because of this, `configure` command has changed; slugs are now set per-site and not globally. Example: `configure dynmap=curse:dynmapforge,modrinth:dynmap` 119 | 120 | - Show a message why a mod wasn't installed if no versions were available [#58](https://github.com/Senth/minecraft-mod-manager/issues/58) 121 | 122 | ### Fixed 123 | 124 | - Mod information is now loaded properly even if it contains character errors [#60](https://github.com/Senth/minecraft-mod-manager/issues/60) 125 | - Mod information is now loaded properly even if JSON file is not strict [#53](https://github.com/Senth/minecraft-mod-manager/issues/53) 126 | - Doesn't remove old jar file [#54](https://github.com/Senth/minecraft-mod-manager/issues/54) 127 | 128 | ## [1.1.0] - 2021-05-30 129 | 130 | ### Added 131 | 132 | - Get application version using `--version` [#49](https://github.com/Senth/minecraft-mod-manager/issues/49) 133 | 134 | ### Fixed 135 | 136 | - Can now install mods that don't specify a mod loader [#35](https://github.com/Senth/minecraft-mod-manager/issues/35) 137 | 138 | ## [1.0.4] - 2021-04-29 139 | 140 | ### Fixed 141 | 142 | - Reinstalling a mod after deleting it manually [#33](https://github.com/Senth/minecraft-mod-manager/issues/33) 143 | - Using `minecraft-mod-manager list` now doesn't display site if none is set 144 | - Using `minecraft-mod-manager list` didn't align properly if no slug was set 145 | - Changed `Alias` to `Slug` when using `list` command (to be consistent) 146 | 147 | ## [1.0.3] - 2021-04-25 148 | 149 | ### Fixed 150 | 151 | - The published version didn't contain all source code. I.e., it didn't run. 152 | 153 | ## [1.0.2] - 2021-04-21 154 | 155 | ### Added 156 | 157 | - Improved logging for when installing and updating. Had been removed in application restructure. 158 | 159 | ### Changed 160 | 161 | - Now only downloads from the first site it finds, will make it download updates from all sites later down the line. 162 | 163 | ### Fixed 164 | 165 | - Missing .com in Modrinth url 166 | - Mod didn't do anything when running 167 | - Crash when running 168 | - Fixed crash when CurseForge supplied a date with missing milliseconds 169 | 170 | ## [1.0.1] - 2021-04-20 171 | 172 | ### Fixed 173 | 174 | - Forgot to add date to CHANGELOG 175 | 176 | ## [1.0.0] - 2021-04-20 177 | 178 | ### Added 179 | 180 | - Modrinth API support (you can now download mods from modrinth as well) [#11](https://github.com/Senth/minecraft-mod-manager/issues/11) 181 | - Can parse and download Forge mods 182 | - Filter installed mod by Fabric/Forge using `--mod-loader` argument [#18](https://github.com/Senth/minecraft-mod-manager/issues/18) 183 | 184 | ### Changed 185 | 186 | - Restructured the whole project and added lots of test. 187 | - Now uses Curse API instead of Selenium with Chrome, thus you don't have to have Chrome installed and more futureproof. 188 | - Improved README with examples and minimum python requirement 189 | 190 | ### Fixed 191 | 192 | - Mods weren't saved correctly to the DB sometimes. 193 | - Doesn't crash when mod isn't fabric [#17](https://github.com/Senth/minecraft-mod-manager/issues/17) 194 | - No longer can update to a wrong version (switching between fabric/forge version) [#10](https://github.com/Senth/minecraft-mod-manager/issues/10) 195 | 196 | ## [0.3.1] - 2021-03-02 197 | 198 | ### Changed 199 | 200 | - README: Moved installation above usage, and fixed out-of-date information 201 | 202 | ## [0.3.0] - 2021-02-26 203 | 204 | ### Added 205 | 206 | - Install feature; can now install mods through `minecraft-mod-manager install modname` 207 | 208 | ## [0.2.2] - 2021-02-25 209 | 210 | ### Fixed 211 | 212 | - Added --pretend and --verbose to README 213 | 214 | ## [0.2.1] - 2021-02-25 215 | 216 | ### Changed 217 | 218 | - Automatic versioning from tags in `setup.py` 219 | 220 | ## [0.2.0] - 2021-02-25 221 | 222 | ### Added 223 | 224 | - `--pretend` option to not save any changes/updates 225 | - `--verbose` for slightly more information 226 | 227 | ### Changed 228 | 229 | - Chromedriver is now installed automatically. No need to download and install it manually every time 🙂 230 | - Made it easier to see which mods are updated and which are not, especially together with --pretend. 231 | 232 | ### Removed 233 | 234 | - User configuration file `config.py`. Was only used for chromedriver location. 235 | 236 | ## [0.1.0] - 2020-11-11 237 | 238 | ### Added 239 | 240 | - Syntax checking for mods supplied during arguments 241 | - Keep information about mod (site and alias) even when it's removed 242 | - List all installed mods (and their settings) with `minecraft-mod-manager list` 243 | 244 | ### Changed 245 | 246 | - Faster runs during configure. Only initialize the webdriver when necessary 247 | - DB structure 248 | 249 | ### Removed 250 | 251 | - --pretend, hadn't been implemented 252 | - --verbose, not necessary, better to use --debug instead 253 | 254 | ## [0.0.2] - 2020-11-10 255 | 256 | ### Added 257 | 258 | - Get possible mod name not only from the `.jar` file but the filename as well 259 | - Search on CurseForge for all these names and only raise an error if no match was found 260 | - Error message if Curse changes their site (so you might have to update this script) 261 | 262 | ### Changed 263 | 264 | - Print out more of what's happening by default 265 | - Stops the script if a mod wasn't found 266 | - Decrease selenium logging (only log errors) 267 | 268 | ### Fixed 269 | 270 | - Running the script on a Linux device doesn't require a `config.py` file 271 | 272 | ## [0.0.1] - 2020-11-09 273 | 274 | ### Added 275 | 276 | - Update feature 277 | - Get existing mods in a directory and add `mod_id` from `.jar` file 278 | - Synchronize mod files with the DB (add and remove) 279 | - Get latest version of mod from CurseForge 280 | - Download mod from CurseForge 281 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020-2021 Matteus Magnusson 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /NEWS.md: -------------------------------------------------------------------------------- 1 | # News 2 | 3 | ## 2022-08-02 — Slow progress and an Alternative CLI 4 | 5 | Hi everyone! 6 | 7 | I have acquired a CurseForge API key, but still want to make it easy to install mods from CurseForge 8 | without applying for a key. 9 | 10 | Maybe that's not possible, but I have some ideas at least for improving `mcman`. 11 | This includes downloading mods from CurseForge. 12 | 13 | For now though, I can point you to an awesome and in my opinion, better alternative: [ferium](https://github.com/gorilla-devs/ferium). 14 | 15 | Cheers, 16 | Senth 17 | 18 | ## 2022-05-08 — CurseForge support disabled (hopefully only for now) 19 | 20 | Hi everyone! 21 | 22 | I'm not sure if you're aware, Overwolf will tomorrow disable the old API for CurseForge which is used by mcman. 23 | 24 | The old API that mcman used is sort of in a gray area legally. 25 | But on a positive note, Overwolf has decided to open up the new API. 26 | Albeit it comes with some limitations; not all mods can be downloaded from 3rd party apps. 27 | 28 | I just applied for an API key for the new API, so hopefully it gets accepted. 29 | For the mods that can't be downloaded I plan to link directly to the CurseForge page for easier manual download. 30 | 31 | The Overwolf client has also become a lot better with more support, but still lacks official linux and OSX support. 32 | 33 | As a server owner though, it requires a bit of changes to how you update the mods. 34 | A tip is to sync your mods folder with Dropbox, that makes it a lot easier. 35 | 36 | This will mean that CurseForge mods will be unavailable for some time. 37 | The change in mcman will only take ~4 hours with updating tests. 38 | The issue is keeping the API key safe. 39 | I have some ideas but it will take time to develop and I also need to check with the 40 | Overwolf team that it's legally possible. 41 | 42 | Anyway, thanks for all the support! 43 | Hopefully we can get mcman up and running again with CurseForge support 🙂 44 | 45 | If it's not accepted, thank you for all the support so far! 46 | 47 | Cheers, 48 | Senth 49 | 50 | ## 2022-03-19 — No GUI Update, focus on CLI improvements 51 | 52 | After some pondering, I've decided to shelve the actual GUI update and instead continue working on the CLI python version. 53 | 54 | There were a lot of reasons against a GUI update. 55 | But mainly, it would require a lot of time and energy to do a GUI and rewrite the entire application. 56 | I'd rather spend time fixing existing bugs, improving the CLI and installation process, and making it more user-friendly. 57 | 58 | ### Pros of shelving GUI 59 | 60 | - Would take 1+ years to catch up to the python version in functionality. 61 | - More energy and time to actually make a fantastic CLI version 62 | - CurseForge already has a good and easy-to-use GUI for updating mods. 63 | - Less competition with CurseForge 64 | 65 | ### Cons of shelving GUI 66 | 67 | - No mod manager can download from either CurseForge or Modrinth with GUI support; 68 | but hopefully someone else will create one? :) 69 | - Less accessible to ordinary people that don't have experiences with CLIs 70 | 71 | ### Who will use mcman? 72 | 73 | With that said, I'm thinking of doubling down on the CLI and making it excellent. 74 | mcman is for technical people and server managers who want an automatic way to update the mods. 75 | Of course, ordinary people can still use it, but the main focus will be to cater to the former's needs. 76 | 77 | ### Future releases 78 | 79 | Now to the fun part, what's to come in the subsequent releases. 80 | 81 | #### v1.3 – Installation improvements 82 | 83 | The main plan is to improve the installation process. 84 | There are a few mods that can't be found, even though they clearly exist on CurseForge. 85 | I have a solid plan for solving this issue, but only half the problems will be solved in v1.3. 86 | The rest will be implemented in v1.4, which focuses on User Experience (UX) 87 | 88 | Automatically installing dependencies will also come in this release :tada: 89 | 90 | This release will also focus on fixing a lot of bug fixes. 91 | 92 | [see issues linked to v1.3](https://github.com/Senth/minecraft-mod-manager/milestone/4) 93 | 94 | #### v1.4 – User Experience (UX) 95 | 96 | The main plan here is to focus on improving accessibility. 97 | Asking for user input when mcman doesn't know how to handle the situation. 98 | Making the CLI consistent, and maybe even changing it up a bit. 99 | 100 | [see issues linked to v1.4](https://github.com/Senth/minecraft-mod-manager/milestone/5) 101 | 102 | ### Thank You! 103 | 104 | As the last message, I want to thank everyone who contributes to this project. 105 | Through ideas, through bug reports, and just by using it :slight_smile: 106 | 107 | Thank you! 108 | / Senth 109 | 110 | ## 2022-01-27 — The GUI Update (late 2022) 111 | 112 | There was an idea of adding a GUI to python. 113 | Unfortunately, python development is not meant for fast GUI development. 114 | So testing changes often requires restarting the application, 115 | this is less than ideal especially when I'm used to changing the code and seeing updates directly. 116 | 117 | It's one of the reasons the progress stopped on this because it was slow and frustrating. 118 | 119 | Thus I started looking for alternatives and found [Electron](https://www.electronjs.org/) to hopefully be a good fit. 120 | This way I can leverage my existing web development skills for creating an GUI instead of learning another. 121 | I still want to support a CLI though, but I think this can be done even on servers. It's a main requirement. 122 | Of course the new App will be cross platform. 123 | 124 | What this means is that I'll start "scratch". But I'd almost have to do that either way with the changes I wanted to do. 125 | 126 | ## 2021-09-10 — The GUI Update (early 2022) 127 | 128 | I'm currently working on a GUI update that simplifies the use of `mcman`. 129 | I'm planning to be done with it early 2022. 130 | At first, I wanted to be done before Minecraft 1.18 releases, but after creating most issues, I doubt that will be the case. 131 | 132 | You can find more detailed information in [#38](https://github.com/Senth/minecraft-mod-manager/issues/38). 133 | Or check out [the project](https://github.com/Senth/minecraft-mod-manager/projects/1) 134 | to check the progress and upcoming tasks 135 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # mcman/mmm (minecraft-mod-manager) 2 | 3 | [![python](https://img.shields.io/pypi/pyversions/minecraft-mod-manager.svg)](https://pypi.python.org/pypi/minecraft-mod-manager) 4 | [![Latest PyPI version](https://img.shields.io/pypi/v/minecraft-mod-manager.svg)](https://pypi.python.org/pypi/minecraft-mod-manager) 5 | [![Total alerts](https://img.shields.io/lgtm/alerts/g/Senth/minecraft-mod-manager.svg?logo=lgtm&logoWidth=18)](https://lgtm.com/projects/g/Senth/minecraft-mod-manager/alerts/) 6 | [![Language grade: Python](https://img.shields.io/lgtm/grade/python/g/Senth/minecraft-mod-manager.svg?logo=lgtm&logoWidth=18)](https://lgtm.com/projects/g/Senth/minecraft-mod-manager/context:python) 7 | 8 | Install and update mods from ~~CurseForge~~ and Modrinth through a simple command. 9 | 10 | ## News — Slow progress and an Alternative CLI (2022-08-02) 11 | 12 | Hi everyone! 13 | 14 | I have acquired a CurseForge API key, but still want to make it easy to install mods from CurseForge 15 | without applying for a key. 16 | 17 | Maybe that's not possible, but I have some ideas at least for improving `mcman`. 18 | This includes downloading mods from CurseForge. 19 | 20 | For now though, I can point you to an awesome and in my opinion, better alternative: [ferium](https://github.com/gorilla-devs/ferium). 21 | 22 | Cheers, 23 | Senth 24 | 25 | _[(News Archive)](./NEWS.md)_ 26 | 27 | ## Features 28 | 29 | - Install mods with `minecraft-mod-manager install mod_name` 30 | - Update all mods with `minecraft-mod-manager update`, `mcman update` or `mmm update` 31 | - Searches on CurseForge and Modrinth for updates on installed mods 32 | - Filter updates by 33 | - Stable (default), beta `--beta`, or alpha `--alpha` releases 34 | - Minecraft version `-v 1.16.4` 35 | - Fabric/Forge mod `--mod-loader fabric` 36 | 37 | ## Installation/Upgrade & Requirements 38 | 39 | 1. Requires at least python 3.8 40 | 1. Install/Upgrade with `$ pip install --user --upgrade minecraft-mod-manager` 41 | 42 | ## Examples 43 | 44 | **Note!** All examples start with `minecraft-mod-manager`, `mcman` or `mmm` 45 | (shorthand commands) then comes the arguments. 46 | 47 | | Arguments | Description | 48 | | ----------------------------------------------- | --------------------------------------------------------------------------------------------------- | 49 | | `install jei` | Searches for jei on all sites and installs the latest version. | 50 | | `install sodium=modrinth` | Install Sodium specifically from modrinth. | 51 | | `install dynmap=curse:dynmapforge` | Install dynmap with slug dynmapforge on Curse. | 52 | | `install sodium=modrinth --mod-loader fabric` | Install fabric version of sodium. Generally not necessary to specify `mod-loader` | 53 | | `install carpet fabric-api sodium lithium` | Easily install many mods. | 54 | | `update` | Update all mods. | 55 | | `update --pretend` | Check what will be updated. Does not change anything. | 56 | | `update sodium lithium phosphor` | Update specific mods. | 57 | | `update -v "1.16.5"` | Updates to latest mod version which works with specified MC version. | 58 | | `update -v "1.16.1"` | If you upgraded the mods, to a higher version (e.g. snapshot), you can easily downgrade them again. | 59 | | `configure sodium=modrinth` | Change the download site for a mod. | 60 | | `configure sodium=` | Doesn't work, known bug! Reset download sites (downloads from all sites again) | 61 | | `configure carpet=curse:fabric-carpet` | Change site slug for a mod. | 62 | | `configure carpet=curse` | If you don't define a slug, you will reset the slug for that mod. | 63 | | `configure sodium=modrinth carpet=curse` | Easily configure multiple mods at the same time. | 64 | | `configure carpet=modrinth,curse:fabric-carpet` | Configure different slugs for different sites. | 65 | | `list` | List all installed mods. | 66 | 67 | ## Full usage 68 | 69 | ```none 70 | positional arguments: 71 | {install,update,configure,list} 72 | Install, update, configure, or list mods 73 | mods 74 | The mods to update or configure. 75 | If no mods are specified during an update, all mods will be updated. 76 | You can specify download sites and slugs for each mod (if necessary) 77 | dynmap=curse 78 | dynmap=curse:dynmapforge 79 | dynmap=curse:dynmapforge,modrinth 80 | dynmap=curse:dynmapforge,modrinth:dynmap 81 | 82 | minecraft: 83 | -d DIR, --dir DIR Location of the mods folder. By default it's the current directory 84 | -v MINECRAFT_VERSION, --minecraft-version MINECRAFT_VERSION 85 | Only update mods to this Minecraft version. Example: -v 1.16.4 86 | --beta Allow beta releases of mods 87 | --alpha Allow alpha and beta releases of mods 88 | --mod-loader {fabric,forge} 89 | Only install mods that use this mod loader. You rarely need to be 90 | this specific. The application figures out for itself which type 91 | you'll likely want to install. 92 | 93 | logging & help: 94 | -h, --help show this help message and exit 95 | --version Print application version 96 | --verbose Print more messages 97 | --debug Turn on debug messages 98 | --pretend Only pretend to install/update/configure. Does not change anything 99 | --no-color Disable color output 100 | ``` 101 | 102 | ## Alternatives 103 | 104 | ### GUI 105 | 106 | - [Overwolf](https://www.overwolf.com/) 107 | - [kaniol-lck/modmanager](https://github.com/kaniol-lck/modmanager) 108 | - [ReviversMC/modget-minecraft](https://github.com/ReviversMC/modget-minecraft) 109 | - [4JX/mCubed](https://github.com/4JX/mCubed) 110 | 111 | ### CLI 112 | 113 | - [gorilla-devs/ferium](https://github.com/gorilla-devs/ferium) 114 | - [sargunv/modsman](https://github.com/sargunv/modsman) 115 | - [tyra314/modweaver](https://github.com/tyra314/modweaver) 116 | 117 | ## Authors 118 | 119 | - Matteus Magnusson, senth.wallace@gmail.com 120 | -------------------------------------------------------------------------------- /minecraft_mod_manager/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Senth/minecraft-mod-manager/eb10f6769c38602772daedcb42c2beca7e67d6b7/minecraft_mod_manager/__init__.py -------------------------------------------------------------------------------- /minecraft_mod_manager/__main__.py: -------------------------------------------------------------------------------- 1 | import signal 2 | 3 | from colored import attr, fg 4 | from tealprint import TealPrint 5 | 6 | from .adapters.repo_impl import RepoImpl 7 | from .app.configure.configure import Configure 8 | from .app.install.install import Install 9 | from .app.show.show import Show 10 | from .app.update.update import Update 11 | from .config import config 12 | from .core.entities.actions import Actions 13 | from .gateways.api.mod_finder import ModFinder 14 | from .gateways.arg_parser import parse_args 15 | from .gateways.http import Http 16 | from .gateways.jar_parser import JarParser 17 | from .gateways.sqlite import Sqlite 18 | 19 | 20 | def signal_handler(signal, frame): 21 | TealPrint.error("Exiting...", color=fg("yellow") + attr("bold"), exit=True) 22 | 23 | 24 | def main(): 25 | signal.signal(signal.SIGINT, signal_handler) 26 | 27 | TealPrint.warning( 28 | "CurseForge has been disabled! See https://github.com/Senth/minecraft-mod-manager for more information." 29 | ) 30 | 31 | args = parse_args() 32 | config.add_arg_settings(args) 33 | 34 | sqlite = Sqlite() 35 | jar_parser = JarParser(config.dir) 36 | http = Http() 37 | repo = RepoImpl(jar_parser, sqlite, http) 38 | finder = ModFinder.create(http) 39 | try: 40 | if config.action == Actions.update: 41 | update = Update(repo, finder) 42 | update.execute(config.arg_mods) 43 | 44 | elif config.action == Actions.install: 45 | install = Install(repo, finder) 46 | install.execute(config.arg_mods) 47 | 48 | elif config.action == Actions.configure: 49 | configure = Configure(repo) 50 | configure.execute(config.arg_mods) 51 | 52 | elif config.action == Actions.list: 53 | show = Show(repo) 54 | show.execute() 55 | finally: 56 | sqlite.close() 57 | 58 | 59 | if __name__ == "__main__": 60 | main() 61 | -------------------------------------------------------------------------------- /minecraft_mod_manager/adapters/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Senth/minecraft-mod-manager/eb10f6769c38602772daedcb42c2beca7e67d6b7/minecraft_mod_manager/adapters/__init__.py -------------------------------------------------------------------------------- /minecraft_mod_manager/adapters/repo_impl.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | from typing import List, Optional, Sequence 3 | 4 | from tealprint import TealPrint 5 | 6 | from ..app.configure.configure_repo import ConfigureRepo 7 | from ..app.install.install_repo import InstallRepo 8 | from ..app.show.show_repo import ShowRepo 9 | from ..app.update.update_repo import UpdateRepo 10 | from ..config import config 11 | from ..core.entities.mod import Mod 12 | from ..core.entities.version_info import VersionInfo 13 | from ..gateways.api.api import Api 14 | from ..gateways.api.modrinth_api import ModrinthApi 15 | from ..gateways.http import Http 16 | from ..gateways.jar_parser import JarParser 17 | from ..gateways.sqlite import Sqlite 18 | from ..utils.log_colors import LogColors 19 | 20 | 21 | class RepoImpl(ConfigureRepo, UpdateRepo, InstallRepo, ShowRepo): 22 | """Cache/Adapter between jar_parser, sqlite and the rest of the application""" 23 | 24 | def __init__(self, jar_parser: JarParser, sqlite: Sqlite, http: Http) -> None: 25 | self.db = sqlite 26 | self.jar_parser = jar_parser 27 | self.mods = self.db.sync_with_dir(jar_parser.mods) 28 | self.http = http 29 | self.apis: List[Api] = [ 30 | ModrinthApi(http), 31 | ] 32 | 33 | def get_mod(self, id: str) -> Optional[Mod]: 34 | for installed_mod in self.mods: 35 | if installed_mod.id == id: 36 | return installed_mod 37 | elif installed_mod.sites: 38 | for site in installed_mod.sites.values(): 39 | if site.slug == id: 40 | return installed_mod 41 | return None 42 | 43 | def update_mod(self, mod: Mod) -> None: 44 | self.db.update_mod(mod) 45 | 46 | def is_installed(self, id: str) -> bool: 47 | for mod in self.mods: 48 | if mod.id == id: 49 | return True 50 | elif mod.sites: 51 | for site in mod.sites.values(): 52 | if site.slug == id: 53 | return True 54 | return False 55 | 56 | def get_all_mods(self) -> Sequence[Mod]: 57 | return self.mods 58 | 59 | def get_mod_from_file(self, filepath: str) -> Optional[Mod]: 60 | return self.jar_parser.get_mod(filepath) 61 | 62 | def remove_mod_file(self, filename: str) -> None: 63 | path = Path(config.dir).joinpath(filename) 64 | path.unlink(missing_ok=True) 65 | 66 | def get_versions(self, mod: Mod) -> List[VersionInfo]: 67 | versions: List[VersionInfo] = [] 68 | 69 | for api in self.apis: 70 | if mod.matches_site(api.site_name): 71 | versions.extend(api.get_all_versions(mod)) 72 | 73 | return versions 74 | 75 | def download(self, url: str, filename: str = "") -> Path: 76 | return Path(self.http.download(url, filename)) 77 | 78 | @staticmethod 79 | def _print_found(): 80 | TealPrint.verbose("Found", color=LogColors.found) 81 | 82 | @staticmethod 83 | def _print_not_found(): 84 | TealPrint.verbose("Not found", color=LogColors.not_found) 85 | -------------------------------------------------------------------------------- /minecraft_mod_manager/adapters/repo_impl_test.py: -------------------------------------------------------------------------------- 1 | from typing import Any, List, Union 2 | 3 | import pytest 4 | from mockito import mock, unstub, verifyStubbedInvocationsAreUsed, when 5 | 6 | from ..adapters.repo_impl import RepoImpl 7 | from ..core.entities.mod import Mod 8 | from ..core.entities.mod_loaders import ModLoaders 9 | from ..core.entities.sites import Site, Sites 10 | from ..core.entities.version_info import Stabilities, VersionInfo 11 | from ..core.errors.mod_not_found_exception import ModNotFoundException 12 | from ..gateways.api.curse_api import CurseApi 13 | from ..gateways.api.modrinth_api import ModrinthApi 14 | from ..gateways.http import Http 15 | from ..gateways.jar_parser import JarParser 16 | from ..gateways.sqlite import Sqlite 17 | 18 | 19 | @pytest.fixture 20 | def jar_parser(): 21 | mocked = mock(JarParser) 22 | mocked.mods = [] # type:ignore 23 | return mocked 24 | 25 | 26 | @pytest.fixture 27 | def sqlite(): 28 | mocked = mock(Sqlite) 29 | when(mocked).sync_with_dir(...).thenReturn([]) 30 | yield mocked 31 | unstub() 32 | 33 | 34 | @pytest.fixture 35 | def http(): 36 | return mock(Http) 37 | 38 | 39 | @pytest.fixture 40 | def repo_impl(jar_parser, sqlite, http): 41 | repo_impl = RepoImpl(jar_parser, sqlite, http) 42 | repo_impl.apis = [CurseApi(http), ModrinthApi(http)] 43 | return repo_impl 44 | 45 | 46 | def mod() -> Mod: 47 | return Mod("", "") 48 | 49 | 50 | def mock_find_mod_id(api: Any, result: Any): 51 | if type(result) == Site: 52 | when(api).find_mod_id(...).thenReturn(result) 53 | elif type(result) == ModNotFoundException: 54 | when(api).find_mod_id(...).thenRaise(result) 55 | 56 | 57 | class TestGetVersions: 58 | def __init__( 59 | self, 60 | name: str, 61 | mod: Mod, 62 | expected: List[VersionInfo] = [], 63 | curse_api_returns: Union[List[VersionInfo], None] = None, 64 | modrinth_api_returns: Union[List[VersionInfo], None] = None, 65 | ) -> None: 66 | self.name = name 67 | self.mod = mod 68 | self.expected = expected 69 | self.curse_api_returns = curse_api_returns 70 | self.modrinth_api_returns = modrinth_api_returns 71 | 72 | 73 | def version(site: Sites) -> VersionInfo: 74 | return VersionInfo( 75 | stability=Stabilities.release, 76 | mod_loaders=set([ModLoaders.fabric]), 77 | site=site, 78 | minecraft_versions=[], 79 | upload_time=0, 80 | download_url="", 81 | number="", 82 | ) 83 | 84 | 85 | @pytest.mark.parametrize( 86 | "test", 87 | [ 88 | TestGetVersions( 89 | name="Get mod from Modrinth API when site type is specified", 90 | mod=Mod("", "", {Sites.modrinth: Site(Sites.modrinth)}), 91 | modrinth_api_returns=[version(site=Sites.modrinth)], 92 | expected=[version(site=Sites.modrinth)], 93 | ), 94 | TestGetVersions( 95 | name="Get mod from Modrinth API when site type is unspecified", 96 | mod=mod(), 97 | modrinth_api_returns=[version(site=Sites.modrinth)], 98 | curse_api_returns=[], 99 | expected=[version(site=Sites.modrinth)], 100 | ), 101 | TestGetVersions( 102 | name="Get mod from Curse API when site type is curse", 103 | mod=Mod("", "", {Sites.curse: Site(Sites.curse)}), 104 | curse_api_returns=[version(site=Sites.curse)], 105 | expected=[version(site=Sites.curse)], 106 | ), 107 | TestGetVersions( 108 | name="Get mod from Curse API when site type is unspecified", 109 | mod=mod(), 110 | modrinth_api_returns=[], 111 | curse_api_returns=[version(site=Sites.curse)], 112 | expected=[version(site=Sites.curse)], 113 | ), 114 | TestGetVersions( 115 | name="Get mod from Curse API when site type is curse", 116 | mod=Mod("", "", {Sites.curse: Site(Sites.curse)}), 117 | curse_api_returns=[version(site=Sites.curse)], 118 | expected=[version(site=Sites.curse)], 119 | ), 120 | TestGetVersions( 121 | name="Get mod from all sites", 122 | mod=mod(), 123 | modrinth_api_returns=[version(site=Sites.modrinth)], 124 | curse_api_returns=[version(site=Sites.curse)], 125 | expected=[version(site=Sites.modrinth), version(site=Sites.curse)], 126 | ), 127 | TestGetVersions( 128 | name="Get mods from all sites when all sites are specified", 129 | mod=Mod( 130 | "", 131 | "", 132 | sites={ 133 | Sites.modrinth: Site(Sites.modrinth), 134 | Sites.curse: Site(Sites.curse), 135 | }, 136 | ), 137 | modrinth_api_returns=[version(site=Sites.modrinth)], 138 | curse_api_returns=[version(site=Sites.curse)], 139 | expected=[version(site=Sites.modrinth), version(site=Sites.curse)], 140 | ), 141 | TestGetVersions( 142 | name="No versions found", 143 | mod=mod(), 144 | modrinth_api_returns=[], 145 | curse_api_returns=[], 146 | expected=[], 147 | ), 148 | ], 149 | ) 150 | def test_get_versions(test: TestGetVersions, repo_impl: RepoImpl): 151 | print(test.name) 152 | 153 | # Mocks API 154 | mock_get_all_versions(repo_impl.apis[0], test.curse_api_returns) 155 | mock_get_all_versions(repo_impl.apis[1], test.modrinth_api_returns) 156 | 157 | result = repo_impl.get_versions(test.mod) 158 | 159 | assert sorted(test.expected) == sorted(result) 160 | 161 | verifyStubbedInvocationsAreUsed() 162 | unstub() 163 | 164 | 165 | def mock_get_all_versions(api: Any, result: Union[List[VersionInfo], ModNotFoundException, None]) -> None: 166 | if type(result) == list: 167 | when(api).get_all_versions(...).thenReturn(result) 168 | 169 | 170 | @pytest.mark.parametrize( 171 | "test_name,mods,input,expected", 172 | [ 173 | ( 174 | "Returns None when no mods", 175 | [], 176 | "id", 177 | None, 178 | ), 179 | ( 180 | "Returns None when specified name", 181 | [ 182 | Mod("id", "name", {}), 183 | ], 184 | "name", 185 | None, 186 | ), 187 | ( 188 | "Returns mod when found by id", 189 | [ 190 | Mod("id2", "name2", {}), 191 | Mod("id", "name", {}), 192 | ], 193 | "id", 194 | Mod("id", "name", {}), 195 | ), 196 | ( 197 | "Returns mod when found by slug", 198 | [ 199 | Mod( 200 | "id", 201 | "name", 202 | { 203 | Sites.modrinth: Site(Sites.modrinth, "utecro", "slug-modrinth"), 204 | Sites.curse: Site(Sites.curse, "utso", "slug-curse"), 205 | }, 206 | ), 207 | ], 208 | "slug-curse", 209 | Mod( 210 | "id", 211 | "name", 212 | { 213 | Sites.modrinth: Site(Sites.modrinth, "utecro", "slug-modrinth"), 214 | Sites.curse: Site(Sites.curse, "utso", "slug-curse"), 215 | }, 216 | ), 217 | ), 218 | ], 219 | ) 220 | def test_get_mod(test_name: str, mods: List[Mod], input: str, expected: Union[Mod, None], repo_impl: RepoImpl): 221 | print(test_name) 222 | 223 | repo_impl.mods = mods 224 | result = repo_impl.get_mod(input) 225 | 226 | assert expected == result 227 | -------------------------------------------------------------------------------- /minecraft_mod_manager/app/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Senth/minecraft-mod-manager/eb10f6769c38602772daedcb42c2beca7e67d6b7/minecraft_mod_manager/app/__init__.py -------------------------------------------------------------------------------- /minecraft_mod_manager/app/configure/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Senth/minecraft-mod-manager/eb10f6769c38602772daedcb42c2beca7e67d6b7/minecraft_mod_manager/app/configure/__init__.py -------------------------------------------------------------------------------- /minecraft_mod_manager/app/configure/configure.py: -------------------------------------------------------------------------------- 1 | from typing import Dict, List, Sequence 2 | 3 | from tealprint import TealPrint 4 | 5 | from ...config import config 6 | from ...core.entities.mod import Mod, ModArg 7 | from ...core.entities.sites import Site, Sites 8 | from ...utils.log_colors import LogColors 9 | from ..configure.configure_repo import ConfigureRepo 10 | 11 | 12 | class Configure: 13 | def __init__(self, repo: ConfigureRepo) -> None: 14 | self._repo = repo 15 | 16 | def execute(self, mods: Sequence[ModArg]) -> None: 17 | mods_to_update: List[Mod] = [] 18 | 19 | for mod_arg in mods: 20 | mod_id_lower = mod_arg.id.lower() 21 | # Find mod 22 | found_mod = self._repo.get_mod(mod_id_lower) 23 | 24 | if not found_mod: 25 | TealPrint.error( 26 | f"Mod {mod_arg.id} not found in installed mods. " 27 | + "Did you misspell the name?\nList installed mods by running: " 28 | + f"{LogColors.command}{config.app_name} list", 29 | exit=True, 30 | ) 31 | return 32 | 33 | if len(mod_arg.sites) > 0: 34 | Configure._update_sites(found_mod, mod_arg.sites) 35 | 36 | # Updating mod 37 | mods_to_update.append(found_mod) 38 | 39 | for mod in mods_to_update: 40 | self._repo.update_mod(mod) 41 | 42 | # Sites info 43 | site_info = "" 44 | for site in mod.sites.values(): 45 | if len(site_info) > 0: 46 | site_info += ", " 47 | site_info += site.get_configure_string() 48 | 49 | TealPrint.info(f"Configured sites for {mod.id}; {{{site_info}}}", color=LogColors.add) 50 | 51 | @staticmethod 52 | def _update_sites(mod: Mod, sites: Dict[Sites, Site]) -> None: 53 | old_sites = mod.sites 54 | mod.sites = sites 55 | 56 | if len(old_sites) > 0: 57 | for old_site in old_sites.values(): 58 | if old_site.id: 59 | if mod.sites and old_site.name in mod.sites: 60 | mod.sites[old_site.name].id = old_site.id 61 | -------------------------------------------------------------------------------- /minecraft_mod_manager/app/configure/configure_repo.py: -------------------------------------------------------------------------------- 1 | from typing import Union 2 | 3 | from ...core.entities.mod import Mod 4 | 5 | 6 | class ConfigureRepo: 7 | def get_mod(self, id: str) -> Union[Mod, None]: 8 | raise NotImplementedError() 9 | 10 | def update_mod(self, mod: Mod) -> None: 11 | raise NotImplementedError() 12 | -------------------------------------------------------------------------------- /minecraft_mod_manager/app/configure/configure_test.py: -------------------------------------------------------------------------------- 1 | from __future__ import unicode_literals 2 | 3 | from typing import List 4 | 5 | import pytest 6 | from mockito import mock, unstub, verifyStubbedInvocationsAreUsed, when 7 | 8 | from ...core.entities.mod import Mod, ModArg 9 | from ...core.entities.sites import Site, Sites 10 | from .configure import Configure 11 | from .configure_repo import ConfigureRepo 12 | 13 | 14 | @pytest.fixture 15 | def mock_repo(): 16 | return mock(ConfigureRepo) 17 | 18 | 19 | def test_abort_when_mod_not_found(mock_repo): 20 | when(mock_repo).get_mod(...).thenReturn(None) 21 | configure = Configure(mock_repo) 22 | input = [ModArg("not-found")] 23 | 24 | with pytest.raises(SystemExit) as e: 25 | configure.execute(input) 26 | 27 | unstub() 28 | assert e.type == SystemExit 29 | 30 | 31 | def test_abort_before_configuring_when_later_mod_not_found(mock_repo): 32 | when(mock_repo).get_mod("found").thenReturn(Mod("", "")) 33 | when(mock_repo).get_mod("not-found").thenReturn(None) 34 | 35 | configure = Configure(mock_repo) 36 | input = [ 37 | ModArg("found"), 38 | ModArg("not-found"), 39 | ] 40 | 41 | with pytest.raises(SystemExit) as e: 42 | configure.execute(input) 43 | 44 | unstub() 45 | assert e.type == SystemExit 46 | 47 | 48 | @pytest.mark.parametrize( 49 | "name,existing,input,expected", 50 | [ 51 | ( 52 | "Set site when specified", 53 | Mod("carpet", ""), 54 | [ModArg("carpet", {Sites.modrinth: Site(Sites.modrinth)})], 55 | Mod("carpet", "", {Sites.modrinth: Site(Sites.modrinth)}), 56 | ), 57 | ( 58 | "Change sites when specified", 59 | Mod("carpet", "", {Sites.modrinth: Site(Sites.modrinth)}), 60 | [ModArg("carpet", {Sites.curse: Site(Sites.curse)})], 61 | Mod("carpet", "", {Sites.curse: Site(Sites.curse)}), 62 | ), 63 | ( 64 | "Mod sites keep when no is specified", 65 | Mod("carpet", "", {Sites.modrinth: Site(Sites.modrinth)}), 66 | [ModArg("carpet", {})], 67 | Mod("carpet", "", {Sites.modrinth: Site(Sites.modrinth)}), 68 | ), 69 | ( 70 | "Keep old info if no site is specified", 71 | Mod("carpet", "", {Sites.modrinth: Site(Sites.modrinth)}), 72 | [ModArg("carpet")], 73 | Mod("carpet", "", {Sites.modrinth: Site(Sites.modrinth)}), 74 | ), 75 | ( 76 | "Set slug when specified", 77 | Mod("carpet", ""), 78 | [ModArg("carpet", {Sites.modrinth: Site(Sites.modrinth, slug="fabric-carpet")})], 79 | Mod("carpet", "", {Sites.modrinth: Site(Sites.modrinth, slug="fabric-carpet")}), 80 | ), 81 | ( 82 | "Remove slug when not specified", 83 | Mod("carpet", "", {Sites.modrinth: Site(Sites.modrinth, slug="fabric-carpet")}), 84 | [ModArg("carpet", {Sites.modrinth: Site(Sites.modrinth)})], 85 | Mod("carpet", "", {Sites.modrinth: Site(Sites.modrinth)}), 86 | ), 87 | ( 88 | "Slug updated when specified", 89 | Mod("carpet", "", {Sites.modrinth: Site(Sites.modrinth, slug="fabric-carpet")}), 90 | [ModArg("carpet", {Sites.modrinth: Site(Sites.modrinth, slug="car")})], 91 | Mod("carpet", "", {Sites.modrinth: Site(Sites.modrinth, slug="car")}), 92 | ), 93 | ( 94 | "Site id is kept when changing other settings", 95 | Mod("carpet", "", {Sites.modrinth: Site(Sites.modrinth, "id", slug="fabric-carpet")}), 96 | [ModArg("carpet", {Sites.modrinth: Site(Sites.modrinth, slug="car")})], 97 | Mod("carpet", "", {Sites.modrinth: Site(Sites.modrinth, "id", slug="car")}), 98 | ), 99 | ], 100 | ) 101 | def test_configure_mod(name: str, existing: Mod, input: List[ModArg], expected: Mod, mock_repo: ConfigureRepo): 102 | when(mock_repo).get_mod(existing.id).thenReturn(existing) 103 | when(mock_repo).update_mod(expected) 104 | 105 | configure = Configure(mock_repo) 106 | configure.execute(input) 107 | 108 | verifyStubbedInvocationsAreUsed() 109 | unstub() 110 | -------------------------------------------------------------------------------- /minecraft_mod_manager/app/download/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Senth/minecraft-mod-manager/eb10f6769c38602772daedcb42c2beca7e67d6b7/minecraft_mod_manager/app/download/__init__.py -------------------------------------------------------------------------------- /minecraft_mod_manager/app/download/download.py: -------------------------------------------------------------------------------- 1 | from typing import List, Sequence 2 | 3 | from tealprint import TealPrint 4 | 5 | from ...config import config 6 | from ...core.entities.mod import Mod, ModArg 7 | from ...core.entities.version_info import VersionInfo 8 | from ...core.errors.download_failed import DownloadFailed 9 | from ...core.errors.mod_file_invalid import ModFileInvalid 10 | from ...core.errors.mod_not_found_exception import ModNotFoundException 11 | from ...core.utils.latest_version_finder import LatestVersionFinder 12 | from ...gateways.api.mod_finder import ModFinder 13 | from ...gateways.http import MaxRetriesExceeded 14 | from ...utils.log_colors import LogColors 15 | from .download_repo import DownloadRepo 16 | 17 | 18 | class Download: 19 | def __init__(self, repo: DownloadRepo, finder: ModFinder): 20 | self._repo = repo 21 | self._finder = finder 22 | 23 | def find_download_and_install(self, mods: Sequence[Mod]) -> None: 24 | mods_not_found: List[ModNotFoundException] = [] 25 | corrupt_mods: List[Mod] = [] 26 | 27 | download_queue: List[Mod] = [] 28 | download_queue.extend(mods) 29 | 30 | # Find latest version of mod 31 | while len(download_queue) > 0: 32 | mod = download_queue.pop() 33 | try: 34 | TealPrint.info(mod.id, color=LogColors.header, push_indent=True) 35 | mod.sites = self._finder.find_mod(mod) 36 | 37 | versions = self._repo.get_versions(mod) 38 | latest_version = LatestVersionFinder.find_latest_version(mod, versions, filter=True) 39 | 40 | if latest_version: 41 | # Different version 42 | if latest_version.upload_time != mod.upload_time: 43 | ok = self._download_latest_version(mod, latest_version) 44 | 45 | # Add possible dependencies to download queue 46 | if ok: 47 | download_queue.extend(self._get_dependencies(latest_version)) 48 | else: 49 | self.on_no_change(mod) 50 | else: 51 | self.on_version_not_found(mod, versions) 52 | 53 | except ModNotFoundException as e: 54 | TealPrint.warning("🔺 Mod not found on any site...") 55 | mods_not_found.append(e) 56 | except MaxRetriesExceeded: 57 | TealPrint.warning("🔺 Max retries exceeded. Skipping...") 58 | mods_not_found.append(ModNotFoundException(mod)) 59 | except ModFileInvalid: 60 | TealPrint.error("❌ Corrupted file.") 61 | corrupt_mods.append(mod) 62 | finally: 63 | TealPrint.pop_indent() 64 | 65 | self._print_errors(mods_not_found, corrupt_mods) 66 | 67 | def _download_latest_version(self, mod: Mod, latest_version: VersionInfo) -> bool: 68 | """Downloads and saves the latest version of the mod.""" 69 | try: 70 | TealPrint.verbose("⬇ Downloading...") 71 | downloaded_mod = self._download(mod, latest_version) 72 | self._update_mod_from_file(downloaded_mod) 73 | self._repo.update_mod(downloaded_mod) 74 | self.on_new_version_downloaded(mod, downloaded_mod) 75 | return True 76 | except (DownloadFailed, MaxRetriesExceeded) as e: 77 | TealPrint.error( 78 | f"🔺 Download failed from {latest_version.site_name}", 79 | ) 80 | TealPrint.error(str(e)) 81 | return False 82 | except ModFileInvalid as e: 83 | # Remove temporary downloaded file 84 | self._repo.remove_mod_file(latest_version.filename) 85 | raise e 86 | 87 | def _get_dependencies(self, latest_version: VersionInfo) -> List[Mod]: 88 | if latest_version.dependencies: 89 | TealPrint.info("Add dependencies to download queue", push_indent=True) 90 | 91 | mods: List[Mod] = [] 92 | 93 | for site, site_ids in latest_version.dependencies.items(): 94 | for site_id in site_ids: 95 | mod = self._finder.get_mod_info(site, site_id) 96 | if mod: 97 | TealPrint.info(f"➕ {mod.name}") 98 | mods.append(mod) 99 | else: 100 | TealPrint.warning(f"Dependency with id '{site_id}' not found on {site.value}") 101 | 102 | if latest_version.dependencies: 103 | TealPrint.pop_indent() 104 | return mods 105 | 106 | def _print_errors(self, mods_not_found, corrupt_mods) -> None: 107 | if len(mods_not_found) > 0: 108 | TealPrint.warning( 109 | f"🔺 {len(mods_not_found)} mods not found", 110 | color=LogColors.header + LogColors.error, 111 | push_indent=True, 112 | ) 113 | for error in mods_not_found: 114 | error.print_message() 115 | TealPrint.pop_indent() 116 | 117 | if len(corrupt_mods) > 0: 118 | TealPrint.warning( 119 | f"❌ {len(corrupt_mods)} corrupt mods", 120 | push_indent=True, 121 | color=LogColors.error + LogColors.header, 122 | ) 123 | for mod in corrupt_mods: 124 | TealPrint.info(f"{mod.name}") 125 | TealPrint.pop_indent() 126 | 127 | def on_new_version_downloaded(self, old: Mod, new: Mod) -> None: 128 | raise NotImplementedError("Not implemented in subclass") 129 | 130 | def on_no_change(self, mod: Mod) -> None: 131 | raise NotImplementedError("Not implemented in subclass") 132 | 133 | def on_version_not_found(self, mod: Mod, versions: List[VersionInfo]) -> None: 134 | raise NotImplementedError("Not implemented in subclass") 135 | 136 | def _update_mod_from_file(self, mod: Mod) -> None: 137 | if not config.pretend and mod.file: 138 | installed_mod = self._repo.get_mod_from_file(mod.file) 139 | if installed_mod: 140 | mod.id = installed_mod.id 141 | mod.name = installed_mod.name 142 | mod.version = installed_mod.version 143 | 144 | def _download(self, mod: ModArg, latest_version: VersionInfo) -> Mod: 145 | downloaded_file = self._repo.download(latest_version.download_url, latest_version.filename) 146 | sites = mod.sites 147 | if not sites: 148 | sites = {} 149 | 150 | add_mod = Mod( 151 | id=mod.id, 152 | name=mod.id, 153 | sites=sites, 154 | file=downloaded_file.name, 155 | upload_time=latest_version.upload_time, 156 | version=latest_version.number, 157 | ) 158 | 159 | return add_mod 160 | -------------------------------------------------------------------------------- /minecraft_mod_manager/app/download/download_repo.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | from typing import List, Optional 3 | 4 | from ...core.entities.mod import Mod 5 | from ...core.entities.version_info import VersionInfo 6 | 7 | 8 | class DownloadRepo: 9 | def get_versions(self, mod: Mod) -> List[VersionInfo]: 10 | raise NotImplementedError() 11 | 12 | def download(self, url: str, filename: str = "") -> Path: 13 | raise NotImplementedError() 14 | 15 | def update_mod(self, mod: Mod) -> None: 16 | raise NotImplementedError() 17 | 18 | def get_mod_from_file(self, filepath: str) -> Optional[Mod]: 19 | raise NotImplementedError() 20 | 21 | def remove_mod_file(self, filename: str) -> None: 22 | raise NotImplementedError() 23 | -------------------------------------------------------------------------------- /minecraft_mod_manager/app/download/download_test.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | from typing import Any, List 3 | 4 | import pytest 5 | from mockito import mock, unstub, verifyStubbedInvocationsAreUsed, when 6 | 7 | from ...core.entities.mod import Mod 8 | from ...core.entities.mod_loaders import ModLoaders 9 | from ...core.entities.sites import Site, Sites 10 | from ...core.entities.version_info import Stabilities, VersionInfo 11 | from ...core.errors.mod_file_invalid import ModFileInvalid 12 | from ...gateways.api.mod_finder import ModFinder 13 | from .download import Download 14 | from .download_repo import DownloadRepo 15 | 16 | 17 | class T: 18 | """Used for testing the Download class. And making use of default values for objects""" 19 | 20 | input = [Mod("just-some-mod-id", "")] 21 | mock_repo: Any 22 | mock_finder: Any 23 | download: Download 24 | version_info = VersionInfo( 25 | stability=Stabilities.release, 26 | mod_loaders=set([ModLoaders.fabric]), 27 | site=Sites.curse, 28 | upload_time=100, 29 | minecraft_versions=[], 30 | download_url="", 31 | number="1.0.0", 32 | ) 33 | 34 | @staticmethod 35 | def set_input(input: List[Mod]): 36 | T.input = input 37 | 38 | 39 | @pytest.mark.parametrize( 40 | "name,prepare_function", 41 | [ 42 | ( 43 | "Call on_new_version_downloaded() when found", 44 | lambda: ( 45 | when(T.mock_repo).download(...).thenReturn(Path("mod.jar")), 46 | when(T.mock_repo).get_versions(...).thenReturn([T.version_info]), 47 | when(T.mock_repo).get_mod_from_file(...).thenReturn(Mod("found", "")), 48 | when(T.mock_repo).update_mod(...), 49 | when(T.download).on_new_version_downloaded(...), 50 | ), 51 | ), 52 | ( 53 | "Remove downloaded file when invalid mod file", 54 | lambda: ( 55 | when(T.mock_repo).download(...).thenReturn(Path("mod.jar")), 56 | when(T.mock_repo).get_versions(...).thenReturn([T.version_info]), 57 | when(T.mock_repo).get_mod_from_file(...).thenRaise(ModFileInvalid(Path("mod.jar"))), 58 | when(T.mock_repo).remove_mod_file(...), 59 | ), 60 | ), 61 | ( 62 | "Update mod_id after download", 63 | lambda: ( 64 | when(T.mock_repo).download(...).thenReturn(Path("mod.jar")), 65 | when(T.mock_repo).get_versions(...).thenReturn([T.version_info]), 66 | when(T.mock_repo).update_mod( 67 | Mod( 68 | "validid", 69 | "name", 70 | sites={Sites.curse: Site(Sites.curse, "", "")}, 71 | version="1.0.0", 72 | file="mod.jar", 73 | upload_time=100, 74 | ) 75 | ), 76 | when(T.mock_repo).get_mod_from_file("mod.jar").thenReturn(Mod("validid", "name", version="1.0.0")), 77 | when(T.download).on_new_version_downloaded(...), 78 | ), 79 | ), 80 | ( 81 | "Download dependency", 82 | lambda: ( 83 | when(T.mock_repo).download(...).thenReturn(Path("mod.jar")), 84 | when(T.mock_repo) 85 | .get_versions(T.input[0]) 86 | .thenReturn( 87 | [ 88 | VersionInfo( 89 | stability=Stabilities.release, 90 | mod_loaders=set([ModLoaders.fabric]), 91 | site=Sites.curse, 92 | upload_time=100, 93 | minecraft_versions=[], 94 | download_url="", 95 | dependencies={Sites.curse: ["123", "456"]}, 96 | filename="parent.jar", 97 | number="1.0.1", 98 | ) 99 | ] 100 | ), 101 | when(T.mock_repo) 102 | .get_versions(Mod("123", "123 Name", {Sites.curse: Site(Sites.curse, "", "")})) 103 | .thenReturn([T.version_info]), 104 | when(T.mock_finder).get_mod_info(Sites.curse, "123").thenReturn(Mod("123", "123 Name")), 105 | when(T.mock_finder).get_mod_info(Sites.curse, "456").thenReturn(None), 106 | when(T.mock_repo).download("", "parent.jar").thenReturn(Path("parent.jar")), 107 | when(T.mock_repo).get_mod_from_file("mod.jar").thenReturn(Mod("123", "123 Name")), 108 | when(T.mock_repo).get_mod_from_file("parent.jar").thenReturn(T.input[0]), 109 | when(T.mock_repo).update_mod(...), 110 | when(T.download).on_new_version_downloaded(...), 111 | ), 112 | ), 113 | ( 114 | "Skip downloading if the upload date is same as the current version", 115 | lambda: ( 116 | T.set_input([Mod("123", "", upload_time=100)]), 117 | when(T.mock_repo).get_versions(...).thenReturn([T.version_info]), 118 | when(T.download).on_no_change(...), 119 | ), 120 | ), 121 | ( 122 | "Downloading if the upload date older than the current version", 123 | lambda: ( 124 | T.set_input([Mod("123", "", upload_time=200)]), 125 | when(T.mock_repo).download(...).thenReturn(Path("mod.jar")), 126 | when(T.mock_repo).get_versions(...).thenReturn([T.version_info]), 127 | when(T.mock_repo).get_mod_from_file(...).thenReturn(Mod("found", "", upload_time=200)), 128 | when(T.mock_repo).update_mod(...), 129 | when(T.download).on_new_version_downloaded(...), 130 | ), 131 | ), 132 | ], 133 | ) 134 | def test_find_download_and_install(name, prepare_function): 135 | print(name) 136 | 137 | T.mock_repo = mock(DownloadRepo) 138 | T.mock_finder = mock(ModFinder) 139 | when(T.mock_finder).find_mod(...).thenReturn({Sites.curse: Site(Sites.curse, "", "")}) 140 | T.download = Download(T.mock_repo, T.mock_finder) 141 | 142 | prepare_function() 143 | T.download.find_download_and_install(T.input) 144 | 145 | verifyStubbedInvocationsAreUsed() 146 | unstub() 147 | -------------------------------------------------------------------------------- /minecraft_mod_manager/app/install/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Senth/minecraft-mod-manager/eb10f6769c38602772daedcb42c2beca7e67d6b7/minecraft_mod_manager/app/install/__init__.py -------------------------------------------------------------------------------- /minecraft_mod_manager/app/install/install.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime 2 | from typing import Dict, List, Sequence 3 | 4 | from tealprint import TealPrint 5 | 6 | from ...config import config 7 | from ...core.entities.mod import Mod, ModArg 8 | from ...core.entities.mod_loaders import ModLoaders 9 | from ...core.entities.version_info import VersionInfo 10 | from ...core.utils.latest_version_finder import LatestVersionFinder 11 | from ...gateways.api.mod_finder import ModFinder 12 | from ...utils.log_colors import LogColors 13 | from ..download.download import Download 14 | from .install_repo import InstallRepo 15 | 16 | 17 | class Install(Download): 18 | def __init__(self, repo: InstallRepo, finder: ModFinder) -> None: 19 | super().__init__(repo, finder) 20 | self._install_repo = repo 21 | 22 | def execute(self, mods: Sequence[ModArg]) -> None: 23 | mods = self._filter_installed_mods(mods) 24 | mods = self._set_mod_loader(mods) 25 | self.find_download_and_install(mods) 26 | 27 | def _filter_installed_mods(self, mods: Sequence[ModArg]) -> Sequence[Mod]: 28 | mods_to_install: List[Mod] = [] 29 | 30 | for mod in mods: 31 | if not self._install_repo.is_installed(mod.id): 32 | mods_to_install.append(Mod.fromModArg(mod)) 33 | else: 34 | TealPrint.info(mod.id, color=LogColors.header, push_indent=True) 35 | TealPrint.info("Skipping... has already been installed", color=LogColors.skip, pop_indent=True) 36 | 37 | return mods_to_install 38 | 39 | def _set_mod_loader(self, mods: Sequence[Mod]) -> Sequence[Mod]: 40 | loader = self._get_mod_loader_to_use() 41 | if loader != ModLoaders.unknown: 42 | for mod in mods: 43 | mod.mod_loader = loader 44 | return mods 45 | 46 | def _get_mod_loader_to_use(self) -> ModLoaders: 47 | mods = self._install_repo.get_all_mods() 48 | 49 | # Count 50 | counts: Dict[ModLoaders, int] = {} 51 | for mod in mods: 52 | loader = mod.mod_loader 53 | if loader != ModLoaders.unknown: 54 | count = 0 55 | if loader in counts: 56 | count = counts[loader] 57 | counts[loader] = count + 1 58 | 59 | # Sort 60 | count_max = 0 61 | loader_max = ModLoaders.unknown 62 | 63 | for loader, count in counts.items(): 64 | if count > count_max: 65 | count_max = count 66 | loader_max = loader 67 | # Multiple max, use none then 68 | elif count == count_max: 69 | loader_max = ModLoaders.unknown 70 | 71 | return loader_max 72 | 73 | def on_new_version_downloaded(self, old: Mod, new: Mod) -> None: 74 | TealPrint.info( 75 | f"🟢 Installed version {new.version}", 76 | color=LogColors.add, 77 | ) 78 | 79 | def on_no_change(self, mod: Mod) -> None: 80 | TealPrint.verbose("🔵 Already installed and up-to-date") 81 | 82 | def on_version_not_found(self, mod: Mod, versions: List[VersionInfo]) -> None: 83 | TealPrint.info("🟨 All versions were filtered out", color=LogColors.skip, push_indent=True) 84 | 85 | latest_unfiltered = LatestVersionFinder.find_latest_version(mod, versions, filter=False) 86 | if latest_unfiltered: 87 | Install._print_latest_unfiltered(mod, latest_unfiltered) 88 | TealPrint.pop_indent() 89 | 90 | @staticmethod 91 | def _print_latest_unfiltered(mod: Mod, latest: VersionInfo) -> None: 92 | if config.filter.version and config.filter.version not in latest.minecraft_versions: 93 | TealPrint.info("The latest version was filtered out by minecraft version") 94 | TealPrint.info( 95 | f"Run without {LogColors.command}--minecraft-version{LogColors.no_color}, or", 96 | push_indent=True, 97 | ) 98 | TealPrint.info( 99 | f"run with {LogColors.command}--minecraft-version VERSION{LogColors.no_color} to download it", 100 | pop_indent=True, 101 | ) 102 | 103 | if LatestVersionFinder.is_filtered_by_stability(latest): 104 | TealPrint.info("The latest version was filtered out by stability", push_indent=True) 105 | TealPrint.info( 106 | f"Run with {LogColors.command}--{latest.stability.value}{LogColors.no_color} to download it", 107 | pop_indent=True, 108 | ) 109 | 110 | if LatestVersionFinder.is_filtered_by_mod_loader(mod.mod_loader, latest): 111 | TealPrint.info("The latest versios was filtered out by mod loader", push_indent=True) 112 | TealPrint.info( 113 | f"Run with {LogColors.command}--mod_loader {next(iter(latest.mod_loaders))}{LogColors.no_color} " 114 | + "to download it", 115 | pop_indent=True, 116 | ) 117 | 118 | TealPrint.info("Latest version", color=LogColors.header, push_indent=True) 119 | width = 20 120 | TealPrint.info("Upload date:".ljust(width) + str(datetime.fromtimestamp(latest.upload_time))) 121 | TealPrint.info("Stability:".ljust(width) + latest.stability.value) 122 | TealPrint.info("Minecraft versions:".ljust(width) + str(latest.minecraft_versions)) 123 | TealPrint.verbose("Filename:".ljust(width) + latest.filename, pop_indent=True) 124 | -------------------------------------------------------------------------------- /minecraft_mod_manager/app/install/install_repo.py: -------------------------------------------------------------------------------- 1 | from typing import Sequence 2 | 3 | from ...core.entities.mod import Mod 4 | from ..download.download_repo import DownloadRepo 5 | 6 | 7 | class InstallRepo(DownloadRepo): 8 | def is_installed(self, id: str) -> bool: 9 | raise NotImplementedError() 10 | 11 | def get_all_mods(self) -> Sequence[Mod]: 12 | raise NotImplementedError() 13 | -------------------------------------------------------------------------------- /minecraft_mod_manager/app/install/install_test.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from mockito import mock, unstub, verifyStubbedInvocationsAreUsed, when 3 | 4 | from ...core.entities.mod import Mod, ModArg 5 | from ...core.entities.mod_loaders import ModLoaders 6 | from ...gateways.api.mod_finder import ModFinder 7 | from .install import Install 8 | from .install_repo import InstallRepo 9 | 10 | 11 | @pytest.fixture 12 | def mock_repo(): 13 | return mock(InstallRepo) 14 | 15 | 16 | @pytest.fixture 17 | def mock_finder(): 18 | return mock(ModFinder) 19 | 20 | 21 | def test_mod_not_installed_when_already_installed(mock_repo, mock_finder): 22 | when(mock_repo).is_installed(...).thenReturn(True) 23 | when(mock_repo).get_all_mods(...).thenReturn([]) 24 | 25 | input = [ModArg("")] 26 | install = Install(mock_repo, mock_finder) 27 | install.execute(input) 28 | 29 | verifyStubbedInvocationsAreUsed() 30 | unstub() 31 | 32 | 33 | def test_call_find_download_and_install(mock_repo, mock_finder): 34 | install = Install(mock_repo, mock_finder) 35 | when(mock_repo).get_all_mods(...).thenReturn([]) 36 | when(install).find_download_and_install(...) 37 | install.execute([]) 38 | 39 | verifyStubbedInvocationsAreUsed() 40 | unstub() 41 | 42 | 43 | def test_set_mod_loader_by_majority(mock_repo, mock_finder): 44 | install = Install(mock_repo, mock_finder) 45 | installed_mods = [ 46 | Mod("", "", mod_loader=ModLoaders.fabric), 47 | Mod("", "", mod_loader=ModLoaders.fabric), 48 | Mod("", "", mod_loader=ModLoaders.forge), 49 | Mod("", "", mod_loader=ModLoaders.unknown), 50 | Mod("", "", mod_loader=ModLoaders.unknown), 51 | Mod("", "", mod_loader=ModLoaders.unknown), 52 | ] 53 | 54 | input = ModArg("") 55 | expected_mod = [Mod("", "", mod_loader=ModLoaders.fabric)] 56 | when(mock_repo).is_installed(...).thenReturn(False) 57 | when(mock_repo).get_all_mods(...).thenReturn(installed_mods) 58 | when(install).find_download_and_install(expected_mod) 59 | 60 | install.execute([input]) 61 | 62 | verifyStubbedInvocationsAreUsed() 63 | unstub() 64 | 65 | 66 | def test_dont_set_mod_loader(mock_repo, mock_finder): 67 | install = Install(mock_repo, mock_finder) 68 | installed_mods = [] 69 | 70 | input = ModArg("") 71 | expected_mod = [Mod("", "")] 72 | when(mock_repo).is_installed(...).thenReturn(False) 73 | when(mock_repo).get_all_mods(...).thenReturn(installed_mods) 74 | when(install).find_download_and_install(expected_mod) 75 | 76 | install.execute([input]) 77 | 78 | verifyStubbedInvocationsAreUsed() 79 | unstub() 80 | 81 | 82 | def test_dont_set_mod_loader_when_no_majority(mock_repo, mock_finder): 83 | install = Install(mock_repo, mock_finder) 84 | installed_mods = [ 85 | Mod("", "", mod_loader=ModLoaders.fabric), 86 | Mod("", "", mod_loader=ModLoaders.forge), 87 | ] 88 | 89 | input = ModArg("") 90 | expected_mod = [Mod("", "")] 91 | when(mock_repo).is_installed(...).thenReturn(False) 92 | when(mock_repo).get_all_mods(...).thenReturn(installed_mods) 93 | when(install).find_download_and_install(expected_mod) 94 | 95 | install.execute([input]) 96 | 97 | verifyStubbedInvocationsAreUsed() 98 | unstub() 99 | -------------------------------------------------------------------------------- /minecraft_mod_manager/app/show/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Senth/minecraft-mod-manager/eb10f6769c38602772daedcb42c2beca7e67d6b7/minecraft_mod_manager/app/show/__init__.py -------------------------------------------------------------------------------- /minecraft_mod_manager/app/show/show.py: -------------------------------------------------------------------------------- 1 | from datetime import date 2 | 3 | from tealprint import TealPrint 4 | 5 | from ...core.entities.mod import Mod 6 | from ...utils.log_colors import LogColors 7 | from .show_repo import ShowRepo 8 | 9 | 10 | class Show: 11 | _padding = 4 12 | 13 | def __init__(self, show_repo: ShowRepo) -> None: 14 | self._repo = show_repo 15 | # Using width of headers as a minimum 16 | self._id_width = 3 17 | self._site_slug_width = 9 18 | self._update_time_width = len("YYYY-MM-DD") 19 | 20 | def execute(self) -> None: 21 | self._installed_mods = self._repo.get_all_mods() 22 | 23 | self._calculate_id_width() 24 | self._calculate_site_slug_width() 25 | 26 | self._print_header() 27 | self._print_row("Mod", "Site:Slug", "Published") 28 | for mod in self._installed_mods: 29 | self._print_mod(mod) 30 | 31 | def _calculate_id_width(self) -> None: 32 | for mod in self._installed_mods: 33 | if len(mod.id) > self._id_width: 34 | self._id_width = len(mod.id) 35 | self._id_width += Show._padding 36 | 37 | def _calculate_site_slug_width(self) -> None: 38 | for mod in self._installed_mods: 39 | site_slug = mod.get_site_slug_string() 40 | if len(site_slug) > self._site_slug_width: 41 | self._site_slug_width = len(site_slug) 42 | 43 | self._site_slug_width += Show._padding 44 | 45 | def _print_header(self) -> None: 46 | TealPrint.info("Installed mods", color=LogColors.header) 47 | 48 | def _print_row(self, id, site_slug, published) -> None: 49 | print(f"{id}".ljust(self._id_width) + f"{site_slug}".ljust(self._site_slug_width) + f"{published}") 50 | 51 | def _print_mod(self, mod: Mod) -> None: 52 | self._print_row(mod.id, mod.get_site_slug_string(), Show._get_date_from_epoch(mod.upload_time)) 53 | 54 | @staticmethod 55 | def _get_date_from_epoch(epoch: int) -> str: 56 | if epoch == 0: 57 | return "???" 58 | 59 | d = date.fromtimestamp(epoch) 60 | return d.strftime("%Y-%m-%d") 61 | -------------------------------------------------------------------------------- /minecraft_mod_manager/app/show/show_repo.py: -------------------------------------------------------------------------------- 1 | from typing import Sequence 2 | 3 | from ...core.entities.mod import Mod 4 | 5 | 6 | class ShowRepo: 7 | def get_all_mods(self) -> Sequence[Mod]: 8 | raise NotImplementedError() 9 | -------------------------------------------------------------------------------- /minecraft_mod_manager/app/show/show_test.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime 2 | from typing import List 3 | 4 | import pytest 5 | from mockito import mock, unstub, verifyStubbedInvocationsAreUsed, when 6 | 7 | from ...core.entities.mod import Mod 8 | from ...core.entities.sites import Site, Sites 9 | from .show import Show 10 | from .show_repo import ShowRepo 11 | 12 | 13 | @pytest.fixture 14 | def installed_mods(): 15 | mods: List[Mod] = [ 16 | Mod( 17 | id="carpet", 18 | name="Carpet", 19 | ), 20 | Mod( 21 | id="fabric_api", 22 | name="Fabric API", 23 | sites={Sites.curse: Site(Sites.curse, "56ey55o", "fabric-api")}, 24 | ), 25 | Mod( 26 | id="jei", 27 | name="Just Enough Items", 28 | sites={Sites.curse: Site(Sites.curse, "56ey55o")}, 29 | ), 30 | Mod( 31 | id="sodium", 32 | name="Sodium", 33 | upload_time=int(datetime(2021, 3, 16).timestamp()), 34 | ), 35 | ] 36 | return mods 37 | 38 | 39 | @pytest.fixture 40 | def repo(installed_mods): 41 | mock_repo = mock(ShowRepo) 42 | when(mock_repo).get_all_mods(...).thenReturn(installed_mods) 43 | yield mock_repo 44 | unstub() 45 | 46 | 47 | @pytest.fixture 48 | def show(repo): 49 | return Show(repo) 50 | 51 | 52 | def test_execute(show: Show): 53 | show.execute() 54 | verifyStubbedInvocationsAreUsed() 55 | -------------------------------------------------------------------------------- /minecraft_mod_manager/app/update/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Senth/minecraft-mod-manager/eb10f6769c38602772daedcb42c2beca7e67d6b7/minecraft_mod_manager/app/update/__init__.py -------------------------------------------------------------------------------- /minecraft_mod_manager/app/update/update.py: -------------------------------------------------------------------------------- 1 | from typing import List, Sequence 2 | 3 | from tealprint import TealPrint 4 | 5 | from ...config import config 6 | from ...core.entities.mod import Mod, ModArg 7 | from ...core.entities.version_info import VersionInfo 8 | from ...gateways.api.mod_finder import ModFinder 9 | from ...utils.log_colors import LogColors 10 | from ..download.download import Download 11 | from .update_repo import UpdateRepo 12 | 13 | 14 | class Update(Download): 15 | def __init__(self, repo: UpdateRepo, finder: ModFinder) -> None: 16 | super().__init__(repo, finder) 17 | self._update_repo = repo 18 | 19 | def execute(self, mods: Sequence[ModArg]) -> None: 20 | mods_to_update: List[Mod] = [] 21 | 22 | # Use all installed mods if mods is empty 23 | if len(mods) == 0: 24 | mods_to_update = list(self._update_repo.get_all_mods()) 25 | else: 26 | for mod_arg in mods: 27 | mod = self._update_repo.get_mod(mod_arg.id) 28 | if mod: 29 | mods_to_update.append(mod) 30 | 31 | self.find_download_and_install(mods_to_update) 32 | 33 | def on_new_version_downloaded(self, old: Mod, new: Mod) -> None: 34 | if new.file: 35 | if Update._has_downloaded_new_file(old, new): 36 | if not config.pretend and old.file: 37 | self._update_repo.remove_mod_file(old.file) 38 | 39 | TealPrint.info(f"🟢 Updated {old.version} -> {new.version}", color=LogColors.updated) 40 | 41 | def on_no_change(self, mod: Mod) -> None: 42 | TealPrint.verbose("🔵 Already up-to-date") 43 | 44 | def on_version_not_found(self, mod: Mod, versions: List[VersionInfo]) -> None: 45 | TealPrint.verbose("🟨 No new version found", color=LogColors.skip) 46 | 47 | @staticmethod 48 | def _has_downloaded_new_file(old: Mod, new: Mod) -> bool: 49 | if new.file and len(new.file) > 0: 50 | if old.file != new.file: 51 | return True 52 | 53 | return False 54 | -------------------------------------------------------------------------------- /minecraft_mod_manager/app/update/update_repo.py: -------------------------------------------------------------------------------- 1 | from typing import Optional, Sequence 2 | 3 | from ...core.entities.mod import Mod 4 | from ...core.entities.version_info import VersionInfo 5 | from ..download.download_repo import DownloadRepo 6 | 7 | 8 | class UpdateRepo(DownloadRepo): 9 | def get_latest_version(self, mod: Mod) -> VersionInfo: 10 | raise NotImplementedError() 11 | 12 | def get_all_mods(self) -> Sequence[Mod]: 13 | raise NotImplementedError() 14 | 15 | def get_mod(self, id: str) -> Optional[Mod]: 16 | raise NotImplementedError() 17 | -------------------------------------------------------------------------------- /minecraft_mod_manager/app/update/update_test.py: -------------------------------------------------------------------------------- 1 | from typing import List, Union 2 | 3 | import pytest 4 | from mockito import mock, unstub, verifyStubbedInvocationsAreUsed, when 5 | 6 | from ...config import config 7 | from ...core.entities.mod import Mod 8 | from ...core.entities.sites import Sites 9 | from ...core.entities.version_info import Stabilities, VersionInfo 10 | from ...gateways.api.mod_finder import ModFinder 11 | from .update import Update 12 | from .update_repo import UpdateRepo 13 | 14 | 15 | @pytest.fixture 16 | def mock_repo(): 17 | return mock(UpdateRepo) 18 | 19 | 20 | @pytest.fixture 21 | def mock_finder(): 22 | return mock(ModFinder) 23 | 24 | 25 | def test_use_all_installed_mods_when_no_mods_are_specified(mock_repo, mock_finder): 26 | mods: List[Mod] = [Mod("1", "one"), Mod("2", "two")] 27 | update = Update(mock_repo, mock_finder) 28 | when(mock_repo, mock_finder).get_all_mods().thenReturn(mods) 29 | when(update).find_download_and_install(...) 30 | 31 | update.execute([]) 32 | 33 | verifyStubbedInvocationsAreUsed() 34 | unstub() 35 | 36 | 37 | def test_call_find_download_and_install(mock_repo, mock_finder): 38 | when(mock_repo, mock_finder).get_all_mods().thenReturn([]) 39 | update = Update(mock_repo, mock_finder) 40 | when(update).find_download_and_install(...) 41 | 42 | update.execute([]) 43 | 44 | verifyStubbedInvocationsAreUsed() 45 | unstub() 46 | 47 | 48 | def version_info(filename: str) -> VersionInfo: 49 | return VersionInfo( 50 | stability=Stabilities.release, 51 | mod_loaders=set(), 52 | site=Sites.curse, 53 | upload_time=1, 54 | minecraft_versions=[], 55 | download_url="", 56 | filename=filename, 57 | number="1.0.0", 58 | ) 59 | 60 | 61 | @pytest.mark.parametrize( 62 | "name,old,new,pretend,expected", 63 | [ 64 | ( 65 | "Remove file when new file has been downloaded", 66 | "old", 67 | "new", 68 | False, 69 | True, 70 | ), 71 | ( 72 | "Keep file when no new file has been downloaded", 73 | "old", 74 | "old", 75 | False, 76 | False, 77 | ), 78 | ( 79 | "Keep old file when new filename is empty", 80 | "old", 81 | "", 82 | False, 83 | False, 84 | ), 85 | ( 86 | "Don't remove old file when it doesn't exist", 87 | None, 88 | "new", 89 | False, 90 | False, 91 | ), 92 | ( 93 | "Don't remove old file when it's empty", 94 | "", 95 | "new", 96 | False, 97 | False, 98 | ), 99 | ( 100 | "Don't remove old file when --pretend is on", 101 | "old", 102 | "new", 103 | True, 104 | False, 105 | ), 106 | ], 107 | ) 108 | def test_on_version_found( 109 | name: str, old: Union[str, None], new: Union[str, None], pretend: bool, expected: bool, mock_repo, mock_finder 110 | ): 111 | print(name) 112 | 113 | config.pretend = pretend 114 | if expected: 115 | when(mock_repo).remove_mod_file(old) 116 | 117 | update = Update(mock_repo, mock_finder) 118 | old_mod = Mod("", "", file=old) 119 | new_mod = Mod("", "", file=new) 120 | update.on_new_version_downloaded(old_mod, new_mod) 121 | 122 | config.pretend = False 123 | verifyStubbedInvocationsAreUsed() 124 | unstub() 125 | -------------------------------------------------------------------------------- /minecraft_mod_manager/config.py: -------------------------------------------------------------------------------- 1 | from importlib.metadata import PackageNotFoundError, version 2 | from pathlib import Path 3 | from typing import Any, List, Literal, Union 4 | 5 | from tealprint import TealConfig, TealLevel 6 | 7 | from .core.entities.mod import ModArg 8 | from .core.entities.mod_loaders import ModLoaders 9 | from .core.entities.version_info import Stabilities 10 | 11 | 12 | class Config: 13 | def __init__(self): 14 | self.app_name: str = __package__.replace("_", "-") 15 | self.app_report_url: str = ( 16 | "https://github.com/Senth/minecraft-mod-manager/issues/new?labels=bug&template=bug_report.md" 17 | ) 18 | self.pretend: bool = False 19 | self.dir: Path = Path(".") 20 | self.action: Literal["install", "update", "configure", "list"] 21 | self.arg_mods: List[ModArg] = [] 22 | self.filter = Filter() 23 | 24 | try: 25 | self.app_version: str = version(self.app_name) 26 | except PackageNotFoundError: 27 | self.app_version: str = "UNKNOWN" 28 | 29 | def add_arg_settings(self, args: Any): 30 | """Set additional configuration from script arguments 31 | 32 | args: 33 | args (list): All the parsed arguments 34 | """ 35 | self.action = args.action 36 | self.pretend = args.pretend 37 | self.arg_mods = args.mods 38 | 39 | if args.debug: 40 | TealConfig.level = TealLevel.debug 41 | elif args.verbose: 42 | TealConfig.level = TealLevel.verbose 43 | TealConfig.colors_enabled = not args.no_color 44 | 45 | if args.dir: 46 | self.dir = Path(args.dir) 47 | 48 | if args.minecraft_version: 49 | self.filter.version = args.minecraft_version 50 | 51 | if args.alpha: 52 | self.filter.stability = Stabilities.alpha 53 | elif args.beta: 54 | self.filter.stability = Stabilities.beta 55 | 56 | if args.mod_loader: 57 | self.filter.loader = ModLoaders.from_name(args.mod_loader) 58 | 59 | 60 | class Filter: 61 | def __init__(self) -> None: 62 | self.stability: Stabilities = Stabilities.release 63 | self.version: Union[str, None] = None 64 | self.loader: ModLoaders = ModLoaders.unknown 65 | 66 | 67 | config = Config() 68 | -------------------------------------------------------------------------------- /minecraft_mod_manager/core/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Senth/minecraft-mod-manager/eb10f6769c38602772daedcb42c2beca7e67d6b7/minecraft_mod_manager/core/__init__.py -------------------------------------------------------------------------------- /minecraft_mod_manager/core/entities/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Senth/minecraft-mod-manager/eb10f6769c38602772daedcb42c2beca7e67d6b7/minecraft_mod_manager/core/entities/__init__.py -------------------------------------------------------------------------------- /minecraft_mod_manager/core/entities/actions.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from enum import Enum 4 | from typing import List, Union 5 | 6 | 7 | class Actions(Enum): 8 | install = "install" 9 | update = "update" 10 | configure = "configure" 11 | list = "list" 12 | 13 | @staticmethod 14 | def from_name(name: str) -> Union[Actions, None]: 15 | for action in Actions: 16 | if action.value == name.lower(): 17 | return action 18 | return None 19 | 20 | @staticmethod 21 | def get_all_names_as_list() -> List[str]: 22 | names: List[str] = [] 23 | for action in Actions: 24 | names.append(action.value) 25 | return names 26 | -------------------------------------------------------------------------------- /minecraft_mod_manager/core/entities/mod.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import re 4 | from typing import Dict, Optional, Set 5 | 6 | from .mod_loaders import ModLoaders 7 | from .sites import Site, Sites 8 | 9 | 10 | class ModArg: 11 | """Mod argument from the CLI""" 12 | 13 | def __init__(self, id: str, sites: Dict[Sites, Site] = {}) -> None: 14 | self.sites = sites 15 | """Where the mod is downloaded from""" 16 | self.id = id 17 | """String identifier of the mod, often case the same as mod name""" 18 | 19 | def matches_site(self, site: Sites) -> bool: 20 | if self.sites: 21 | return site in self.sites 22 | return True 23 | 24 | def get_site_slug_string(self) -> str: 25 | sites = "" 26 | if self.sites: 27 | for site in self.sites.values(): 28 | if len(sites) > 0: 29 | sites += ", " 30 | sites += site.get_configure_string() 31 | 32 | return sites 33 | 34 | def __str__(self) -> str: 35 | return f"{self.id}={self.get_site_slug_string()}" 36 | 37 | def __repr__(self) -> str: 38 | return str(self.__members()) 39 | 40 | def __lt__(self, other: ModArg) -> bool: 41 | return self.id < other.id 42 | 43 | def __le__(self, other: ModArg) -> bool: 44 | return self.id <= other.id 45 | 46 | def __gt__(self, other: ModArg) -> bool: 47 | return self.id > other.id 48 | 49 | def __ge__(self, other: ModArg) -> bool: 50 | return self.id >= other.id 51 | 52 | def __members(self): 53 | return ( 54 | self.id, 55 | self.sites, 56 | ) 57 | 58 | def __eq__(self, other) -> bool: 59 | if type(other) is type(self): 60 | return self.__members() == other.__members() 61 | 62 | return False 63 | 64 | def __hash__(self) -> int: 65 | return hash(self.__members()) 66 | 67 | 68 | class Mod(ModArg): 69 | def __init__( 70 | self, 71 | id: str, 72 | name: str, 73 | sites: Dict[Sites, Site] = {}, 74 | version: Optional[str] = None, 75 | file: Optional[str] = None, 76 | upload_time: int = 0, 77 | mod_loader: ModLoaders = ModLoaders.unknown, 78 | ): 79 | super().__init__(id, sites) 80 | self.name = name 81 | self.version = version 82 | self.file = file 83 | self.mod_loader = mod_loader 84 | self.upload_time = upload_time 85 | """When this version of the mod was uploaded to the repository""" 86 | 87 | @staticmethod 88 | def fromModArg(mod_arg: ModArg) -> Mod: 89 | return Mod(mod_arg.id, mod_arg.id, mod_arg.sites) 90 | 91 | def __str__(self) -> str: 92 | return f"{self.id}-{self.version} ({self.name}) [{self.mod_loader.value}]" 93 | 94 | def __repr__(self) -> str: 95 | return str(self.__members()) 96 | 97 | def get_possible_slugs(self) -> Set[str]: 98 | possible_names: Set[str] = set() 99 | 100 | # Add from id 101 | possible_names.add(self.id.replace("_", "-")) 102 | 103 | # Get mod name from filename 104 | if self.file: 105 | match = re.match(r"(\w+-\w+-\w+|\w+-\w+|\w+)-", self.file) 106 | 107 | if match and match.lastindex == 1: 108 | # Add basic name 109 | filename = match.group(1).lower().replace("_", "-") 110 | possible_names.add(filename) 111 | 112 | # Remove possible 'fabric' from the name 113 | without_fabric = re.sub(r"-fabric\w*|fabric\w*-", "", filename) 114 | possible_names.add(without_fabric) 115 | 116 | # Split parts of name 117 | split_names: Set[str] = set() 118 | for possible_name in possible_names: 119 | split_names.update(possible_name.split("-")) 120 | possible_names.update(split_names) 121 | 122 | return possible_names 123 | 124 | def __members(self): 125 | return ( 126 | self.id, 127 | self.name, 128 | self.sites, 129 | self.version, 130 | self.file, 131 | self.upload_time, 132 | self.mod_loader, 133 | ) 134 | 135 | def __eq__(self, other) -> bool: 136 | if type(other) is type(self): 137 | return self.__members() == other.__members() 138 | return False 139 | 140 | def __hash__(self): 141 | return hash(self.__members()) 142 | -------------------------------------------------------------------------------- /minecraft_mod_manager/core/entities/mod_loaders.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from enum import Enum 4 | 5 | 6 | class ModLoaders(Enum): 7 | unknown = "unknown" 8 | fabric = "fabric" 9 | forge = "forge" 10 | quilt = "quilt" 11 | bukkit = "bukkit" 12 | paper = "paper" 13 | purpur = "purpur" 14 | spigot = "spigot" 15 | 16 | @staticmethod 17 | def from_name(name: str) -> ModLoaders: 18 | for mod_loader in ModLoaders: 19 | if mod_loader.value == name.lower(): 20 | return mod_loader 21 | return ModLoaders.unknown 22 | -------------------------------------------------------------------------------- /minecraft_mod_manager/core/entities/sites.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from enum import Enum 4 | from typing import Union 5 | 6 | 7 | class Site: 8 | def __init__(self, name: Sites, id: Union[str, None] = None, slug: Union[str, None] = None) -> None: 9 | self.id = id 10 | self.name = name 11 | self.slug = slug 12 | 13 | def get_configure_string(self) -> str: 14 | slug = "" 15 | if self.slug: 16 | slug = f":{self.slug}" 17 | return f"{self.name.value}{slug}" 18 | 19 | def __str__(self) -> str: 20 | return f"Site({self.name.value}, {self.id}, {self.slug})" 21 | 22 | def __repr__(self) -> str: 23 | return str(self.__members()) 24 | 25 | def __members(self): 26 | return ( 27 | self.id, 28 | self.name, 29 | self.slug, 30 | ) 31 | 32 | def __eq__(self, other) -> bool: 33 | if type(other) is type(self): 34 | return self.__members() == other.__members() 35 | return False 36 | 37 | def __hash__(self) -> int: 38 | return hash(self.__members()) 39 | 40 | 41 | class Sites(Enum): 42 | curse = "curse" 43 | modrinth = "modrinth" 44 | 45 | @staticmethod 46 | def all() -> str: 47 | all = "" 48 | for name in Sites: 49 | if len(all) > 0: 50 | all += "|" 51 | all += name.value 52 | return all 53 | -------------------------------------------------------------------------------- /minecraft_mod_manager/core/entities/version_info.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from enum import Enum 4 | from typing import Dict, List, Set 5 | 6 | from .mod_loaders import ModLoaders 7 | from .sites import Sites 8 | 9 | 10 | class Stabilities(Enum): 11 | release = "release" 12 | beta = "beta" 13 | alpha = "alpha" 14 | unknown = "unknown" 15 | 16 | @staticmethod 17 | def from_name(name: str) -> Stabilities: 18 | for stability in Stabilities: 19 | if stability.value.lower() == name.lower(): 20 | return stability 21 | return Stabilities.unknown 22 | 23 | 24 | class VersionInfo: 25 | def __init__( 26 | self, 27 | stability: Stabilities, 28 | mod_loaders: Set[ModLoaders], 29 | site: Sites, 30 | upload_time: int, 31 | minecraft_versions: List[str], 32 | download_url: str, 33 | number: str, 34 | filename: str = "", 35 | mod_name: str = "", 36 | dependencies: Dict[Sites, List[str]] = {}, 37 | ) -> None: 38 | self.stability = stability 39 | self.mod_loaders = mod_loaders 40 | self.site_name = site 41 | self.upload_time = upload_time 42 | self.minecraft_versions = minecraft_versions 43 | self.download_url = download_url 44 | self.number = number 45 | self.filename = filename 46 | self.name = mod_name 47 | self.dependencies = dependencies 48 | 49 | def __str__(self) -> str: 50 | return f"{self.minecraft_versions}; uploaded {self.upload_time}" 51 | 52 | def __repr__(self) -> str: 53 | return str(self.__members()) 54 | 55 | def __members(self): 56 | return ( 57 | self.stability, 58 | self.mod_loaders, 59 | self.site_name, 60 | self.upload_time, 61 | self.minecraft_versions, 62 | self.download_url, 63 | self.number, 64 | self.filename, 65 | self.name, 66 | self.dependencies, 67 | ) 68 | 69 | def __eq__(self, other) -> bool: 70 | if type(other) is type(self): 71 | return self.__members() == other.__members() 72 | return False 73 | 74 | def __hash__(self): 75 | return hash(self.__members()) 76 | 77 | def __lt__(self, other: VersionInfo) -> bool: 78 | if self.upload_time < other.upload_time: 79 | return True 80 | if self.name < other.name: 81 | return True 82 | if self.site_name.value < other.site_name.value: 83 | return True 84 | return False 85 | 86 | def __le__(self, other: VersionInfo) -> bool: 87 | if self.upload_time <= other.upload_time: 88 | return True 89 | if self.name <= other.name: 90 | return True 91 | if self.site_name.value <= other.site_name.value: 92 | return True 93 | return False 94 | 95 | def __gt__(self, other: VersionInfo) -> bool: 96 | return not self.__le__(other) 97 | 98 | def __ge__(self, other: VersionInfo) -> bool: 99 | return not self.__lt__(other) 100 | -------------------------------------------------------------------------------- /minecraft_mod_manager/core/errors/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Senth/minecraft-mod-manager/eb10f6769c38602772daedcb42c2beca7e67d6b7/minecraft_mod_manager/core/errors/__init__.py -------------------------------------------------------------------------------- /minecraft_mod_manager/core/errors/download_failed.py: -------------------------------------------------------------------------------- 1 | class DownloadFailed(Exception): 2 | def __init__(self, status_code: int, reason: str, body: str) -> None: 3 | self.status_code = status_code 4 | self.reason = reason 5 | self.body = body 6 | 7 | def __str__(self) -> str: 8 | return f"Status code: {self.status_code}, {self.reason}" 9 | -------------------------------------------------------------------------------- /minecraft_mod_manager/core/errors/mod_already_exists.py: -------------------------------------------------------------------------------- 1 | from ..entities.mod import ModArg 2 | 3 | 4 | class ModAlreadyExists(Exception): 5 | def __init__(self, mod: ModArg) -> None: 6 | self.mod = mod 7 | 8 | def __str__(self) -> str: 9 | return f"Mod {self.mod.id} already found in the the db.\n" 10 | -------------------------------------------------------------------------------- /minecraft_mod_manager/core/errors/mod_file_invalid.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | 3 | 4 | class ModFileInvalid(Exception): 5 | def __init__(self, file: Path) -> None: 6 | self.file = file 7 | 8 | def __str__(self) -> str: 9 | return f"File {self.file} is not a valid Mod .jar file" 10 | -------------------------------------------------------------------------------- /minecraft_mod_manager/core/errors/mod_not_found_exception.py: -------------------------------------------------------------------------------- 1 | from tealprint import TealPrint 2 | 3 | from ...config import config 4 | from ...utils.log_colors import LogColors 5 | from ..entities.mod import ModArg 6 | 7 | 8 | class ModNotFoundException(Exception): 9 | def __init__(self, mod: ModArg) -> None: 10 | self.mod = mod 11 | 12 | def print_message(self) -> None: 13 | mod_name = self.mod.id 14 | 15 | TealPrint.info(f"{mod_name}", color=LogColors.header, push_indent=True) 16 | TealPrint.info("Check so that it's slug is correct. You can set the slug by running:") 17 | TealPrint.info( 18 | f"{config.app_name} configure {self.mod.id}=curse:mod-slug,modrinth:mod-slug", 19 | color=LogColors.command, 20 | pop_indent=True, 21 | ) 22 | -------------------------------------------------------------------------------- /minecraft_mod_manager/core/utils/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Senth/minecraft-mod-manager/eb10f6769c38602772daedcb42c2beca7e67d6b7/minecraft_mod_manager/core/utils/__init__.py -------------------------------------------------------------------------------- /minecraft_mod_manager/core/utils/latest_version_finder.py: -------------------------------------------------------------------------------- 1 | from typing import List, Union 2 | 3 | from ...config import config 4 | from ..entities.mod import Mod 5 | from ..entities.mod_loaders import ModLoaders 6 | from ..entities.version_info import Stabilities, VersionInfo 7 | 8 | 9 | class LatestVersionFinder: 10 | @staticmethod 11 | def find_latest_version(mod: Mod, versions: List[VersionInfo], filter: bool) -> Union[VersionInfo, None]: 12 | latest_version: Union[VersionInfo, None] = None 13 | 14 | for version in versions: 15 | if not filter or not LatestVersionFinder.is_filtered(mod, version): 16 | if not latest_version or version.upload_time > latest_version.upload_time: 17 | latest_version = version 18 | 19 | return latest_version 20 | 21 | @staticmethod 22 | def is_filtered(mod: Mod, version: VersionInfo) -> bool: 23 | if LatestVersionFinder.is_filtered_by_stability(version): 24 | return True 25 | if LatestVersionFinder.is_filtered_by_mc_version(version): 26 | return True 27 | if LatestVersionFinder.is_filtered_by_mod_loader(mod.mod_loader, version): 28 | return True 29 | return False 30 | 31 | @staticmethod 32 | def is_filtered_by_stability(version: VersionInfo) -> bool: 33 | if config.filter.stability == Stabilities.alpha: 34 | return False 35 | elif config.filter.stability == Stabilities.beta: 36 | return version.stability == Stabilities.alpha 37 | elif config.filter.stability == Stabilities.release: 38 | return version.stability == Stabilities.beta or version.stability == Stabilities.alpha 39 | return False 40 | 41 | @staticmethod 42 | def is_filtered_by_mc_version(version: VersionInfo) -> bool: 43 | if config.filter.version: 44 | return config.filter.version not in version.minecraft_versions 45 | return False 46 | 47 | @staticmethod 48 | def is_filtered_by_mod_loader(prev: ModLoaders, version: VersionInfo) -> bool: 49 | """Check whether this version should be filtered by mod_loader. 50 | Will check both if a global filter has been set, otherwise it will match against 51 | the already install version's mod loader (if one exists). 52 | 53 | Args: 54 | prev (ModLoaders): The mod loader of the existing installed version 55 | version (VersionInfo): Downloaded version to filter 56 | 57 | Returns: 58 | bool: True -> filter/remove; false -> keep it 59 | """ 60 | # No mod loaders specified in version, match all 61 | if len(version.mod_loaders) == 0: 62 | return False 63 | 64 | # Use global filter 65 | elif config.filter.loader != ModLoaders.unknown: 66 | return config.filter.loader not in version.mod_loaders 67 | 68 | # Use filter from previous version 69 | else: 70 | if prev != ModLoaders.unknown: 71 | return prev not in version.mod_loaders 72 | 73 | return False 74 | -------------------------------------------------------------------------------- /minecraft_mod_manager/gateways/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Senth/minecraft-mod-manager/eb10f6769c38602772daedcb42c2beca7e67d6b7/minecraft_mod_manager/gateways/__init__.py -------------------------------------------------------------------------------- /minecraft_mod_manager/gateways/api/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Senth/minecraft-mod-manager/eb10f6769c38602772daedcb42c2beca7e67d6b7/minecraft_mod_manager/gateways/api/__init__.py -------------------------------------------------------------------------------- /minecraft_mod_manager/gateways/api/api.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime 2 | from typing import List 3 | 4 | from ...core.entities.mod import Mod 5 | from ...core.entities.sites import Site, Sites 6 | from ...core.entities.version_info import VersionInfo 7 | from ..http import Http 8 | 9 | 10 | class Api: 11 | def __init__(self, http: Http, site_name: Sites) -> None: 12 | self.http = http 13 | self.site_name = site_name 14 | 15 | def get_all_versions(self, mod: Mod) -> List[VersionInfo]: 16 | raise NotImplementedError() 17 | 18 | def search_mod(self, search: str) -> List[Site]: 19 | raise NotImplementedError() 20 | 21 | def get_mod_info(self, site_id: str) -> Mod: 22 | """Get mod info from the id. 23 | Throws ModNotFoundException if it's not found. 24 | """ 25 | raise NotImplementedError() 26 | 27 | @staticmethod 28 | def _to_epoch_time(date_string: str) -> int: 29 | # Has milliseconds 30 | date = 0 31 | try: 32 | date = datetime.strptime(date_string, "%Y-%m-%dT%H:%M:%S.%f%z") 33 | except ValueError: 34 | date = datetime.strptime(date_string, "%Y-%m-%dT%H:%M:%S%z") 35 | 36 | return round(date.timestamp()) 37 | -------------------------------------------------------------------------------- /minecraft_mod_manager/gateways/api/curse_api.py: -------------------------------------------------------------------------------- 1 | import re 2 | from enum import Enum 3 | from typing import Any, Dict, List, Set 4 | 5 | from ...core.entities.mod import Mod, ModArg 6 | from ...core.entities.mod_loaders import ModLoaders 7 | from ...core.entities.sites import Site, Sites 8 | from ...core.entities.version_info import Stabilities, VersionInfo 9 | from ...core.errors.mod_not_found_exception import ModNotFoundException 10 | from ..http import Http 11 | from .api import Api 12 | 13 | _base_url = "https://addons-ecs.forgesvc.net/api/v2/addon" 14 | 15 | 16 | class DependencyTypes(Enum): 17 | required = 3 18 | 19 | 20 | class CurseApi(Api): 21 | _filename_to_version_regex = re.compile(r"((\d{2}w\d{2}[a-f]-)?\d+\.\d+(\.\d+)?.*).*\.jar") 22 | _starts_with_number_regex = re.compile(r"^\d+.*") 23 | 24 | def __init__(self, http: Http) -> None: 25 | super().__init__(http, Sites.curse) 26 | 27 | def get_all_versions(self, mod: Mod) -> List[VersionInfo]: 28 | versions: List[VersionInfo] = [] 29 | files = self.http.get(CurseApi._make_files_url(mod)) 30 | for file in files: 31 | version = CurseApi._file_to_version_info(file) 32 | version.name = mod.name 33 | versions.append(version) 34 | 35 | return versions 36 | 37 | @staticmethod 38 | def _make_files_url(mod: Mod) -> str: 39 | if mod.sites: 40 | return f"{_base_url}/{mod.sites[Sites.curse].id}/files" 41 | raise RuntimeError("No site id found") 42 | 43 | def search_mod(self, search: str) -> List[Site]: 44 | mods: List[Site] = [] 45 | json = self.http.get(CurseApi._make_search_url(search)) 46 | for curse_mod in json: 47 | if "slug" in curse_mod and "id" in curse_mod: 48 | slug = curse_mod["slug"] 49 | site_id = curse_mod["id"] 50 | mods.append(Site(Sites.curse, str(site_id), slug)) 51 | return mods 52 | 53 | @staticmethod 54 | def _make_search_url(search: str) -> str: 55 | return f"{_base_url}/search?gameId=432§ionId=6&searchFilter={search}" 56 | 57 | def get_mod_info(self, site_id: str) -> Mod: 58 | json = self.http.get(self._make_get_mod_url(site_id)) 59 | if json and "id" in json and "slug" in json and "name" in json: 60 | return Mod( 61 | id="", 62 | name=json["name"], 63 | sites={Sites.curse: Site(Sites.curse, str(json["id"]), json["slug"])}, 64 | ) 65 | raise ModNotFoundException(ModArg(site_id)) 66 | 67 | @staticmethod 68 | def _make_get_mod_url(site_id: str) -> str: 69 | return f"{_base_url}/{site_id}" 70 | 71 | @staticmethod 72 | def _file_to_version_info(file_data: Any) -> VersionInfo: 73 | # Find required dependencies 74 | dependencyList: List[str] = [] 75 | for dependency in file_data["dependencies"]: 76 | if {"addonId", "type"}.issubset(dependency): 77 | t = dependency["type"] 78 | if t == DependencyTypes.required.value: 79 | dependencyList.append(str(dependency["addonId"])) 80 | 81 | dependencyMap: Dict[Sites, List[str]] = {} 82 | if dependencyList: 83 | dependencyMap[Sites.curse] = dependencyList 84 | 85 | # Create VersionInfo 86 | return VersionInfo( 87 | stability=CurseApi._to_release_type(file_data["releaseType"]), 88 | mod_loaders=CurseApi._to_mod_loaders(file_data["gameVersion"]), 89 | site=Sites.curse, 90 | upload_time=Api._to_epoch_time(file_data["fileDate"]), 91 | minecraft_versions=file_data["gameVersion"], 92 | download_url=file_data["downloadUrl"], 93 | number=CurseApi._get_version_from_filename(file_data["fileName"]), 94 | filename=file_data["fileName"], 95 | dependencies=dependencyMap, 96 | ) 97 | 98 | @staticmethod 99 | def _to_release_type(value: int) -> Stabilities: 100 | if value == 1: 101 | return Stabilities.release 102 | elif value == 2: 103 | return Stabilities.beta 104 | elif value == 3: 105 | return Stabilities.alpha 106 | return Stabilities.unknown 107 | 108 | @staticmethod 109 | def _get_version_from_filename(filename: str) -> str: 110 | match = CurseApi._filename_to_version_regex.search(filename) 111 | if match: 112 | return match.group(1) 113 | return "Unknown" 114 | 115 | @staticmethod 116 | def _to_mod_loaders(loaders: List[str]) -> Set[ModLoaders]: 117 | mod_loaders: Set[ModLoaders] = set() 118 | 119 | for loader in loaders: 120 | if not CurseApi._starts_with_number_regex.match(loader): 121 | mod_loaders.add(ModLoaders.from_name(loader)) 122 | 123 | return mod_loaders 124 | -------------------------------------------------------------------------------- /minecraft_mod_manager/gateways/api/curse_api_test.py: -------------------------------------------------------------------------------- 1 | import json 2 | from pathlib import Path 3 | from typing import Optional 4 | 5 | import pytest 6 | from mockito import mock, unstub, verifyStubbedInvocationsAreUsed, when 7 | 8 | from ...core.entities.mod import Mod 9 | from ...core.entities.mod_loaders import ModLoaders 10 | from ...core.entities.sites import Site, Sites 11 | from ...core.entities.version_info import Stabilities, VersionInfo 12 | from ...core.errors.mod_not_found_exception import ModNotFoundException 13 | from ..http import Http 14 | from .curse_api import CurseApi 15 | 16 | testdata_dir = Path(__file__).parent.joinpath("testdata").joinpath("curse_api") 17 | search_carpet_file = testdata_dir.joinpath("search_carpet.json") 18 | files_carpet_file = testdata_dir.joinpath("files_carpet.json") 19 | mod_info_file = testdata_dir.joinpath("mod_info.json") 20 | 21 | 22 | @pytest.fixture 23 | def carpet_search(): 24 | with open(search_carpet_file) as file: 25 | return json.load(file) 26 | 27 | 28 | @pytest.fixture 29 | def carpet_files(): 30 | with open(files_carpet_file) as file: 31 | return json.load(file) 32 | 33 | 34 | @pytest.fixture 35 | def mod_info(): 36 | with open(mod_info_file) as file: 37 | return json.load(file) 38 | 39 | 40 | @pytest.fixture 41 | def http(): 42 | mocked = mock(Http) 43 | yield mocked 44 | unstub() 45 | 46 | 47 | @pytest.fixture 48 | def api(http): 49 | return CurseApi(http) 50 | 51 | 52 | site_id = "349239" 53 | 54 | 55 | def mod(id="carpet", name="Carpet", site_slug="carpet", site_id=site_id, file: Optional[str] = None): 56 | return Mod(id=id, name=name, sites={Sites.curse: Site(Sites.curse, site_id, site_slug)}, file=file) 57 | 58 | 59 | def test_search_mod(api: CurseApi, carpet_search): 60 | when(api.http).get(...).thenReturn(carpet_search) 61 | expected = [ 62 | Site(Sites.curse, "349239", "carpet"), 63 | Site(Sites.curse, "361689", "carpet-stairs-mod"), 64 | Site(Sites.curse, "349240", "carpet-extra"), 65 | Site(Sites.curse, "409947", "ceiling-carpets"), 66 | Site(Sites.curse, "397510", "carpet-tis-addition"), 67 | Site(Sites.curse, "315944", "forgedcarpet"), 68 | Site(Sites.curse, "441529", "ivan-carpet-addition"), 69 | Site(Sites.curse, "229429", "weather-carpets-mod"), 70 | Site(Sites.curse, "443142", "carpet-without-player"), 71 | Site(Sites.curse, "417744", "carpet-wood"), 72 | Site(Sites.curse, "264320", "no-collide-carpets"), 73 | Site(Sites.curse, "40519", "carpet-mod"), 74 | ] 75 | 76 | actual = api.search_mod("carpet") 77 | 78 | verifyStubbedInvocationsAreUsed() 79 | unstub() 80 | 81 | assert expected == actual 82 | 83 | 84 | def test_get_mod_info(api: CurseApi, mod_info): 85 | when(api.http).get(...).thenReturn(mod_info) 86 | expected = Mod( 87 | id="", 88 | name="Litematica", 89 | sites={Sites.curse: Site(Sites.curse, "308892", "litematica")}, 90 | ) 91 | 92 | actual = api.get_mod_info("123") 93 | 94 | verifyStubbedInvocationsAreUsed() 95 | unstub() 96 | 97 | assert expected == actual 98 | 99 | 100 | def test_get_mod_info_not_found(api: CurseApi): 101 | when(api.http).get(...).thenReturn({"error": "not found"}) 102 | 103 | with pytest.raises(ModNotFoundException): 104 | api.get_mod_info("123") 105 | 106 | verifyStubbedInvocationsAreUsed() 107 | unstub() 108 | 109 | 110 | def test_get_all_versions(api: CurseApi, carpet_files): 111 | when(api.http).get(...).thenReturn(carpet_files) 112 | expected = [ 113 | VersionInfo( 114 | stability=Stabilities.beta, 115 | mod_loaders=set([ModLoaders.forge]), 116 | site=Sites.curse, 117 | mod_name="Carpet", 118 | upload_time=1585794423, 119 | minecraft_versions=["1.16-Snapshot", "Forge"], 120 | download_url="https://edge.forgecdn.net/files/2918/924/fabric-carpet-20w13b-1.3.17+v200401.jar", 121 | filename="fabric-carpet-20w13b-1.3.17+v200401.jar", 122 | dependencies={Sites.curse: ["1337", "1338"]}, 123 | number="20w13b-1.3.17+v200401", 124 | ), 125 | VersionInfo( 126 | stability=Stabilities.alpha, 127 | mod_loaders=set([]), 128 | site=Sites.curse, 129 | mod_name="Carpet", 130 | upload_time=1571975688, 131 | minecraft_versions=["1.14.4"], 132 | download_url="https://edge.forgecdn.net/files/2815/968/fabric-carpet-1.14.4-1.2.0+v191024.jar", 133 | filename="fabric-carpet-1.14.4-1.2.0+v191024.jar", 134 | number="1.14.4-1.2.0+v191024", 135 | ), 136 | VersionInfo( 137 | stability=Stabilities.release, 138 | mod_loaders=set([ModLoaders.fabric]), 139 | site=Sites.curse, 140 | mod_name="Carpet", 141 | upload_time=1618425238, 142 | minecraft_versions=["Fabric", "1.16.5", "1.16.4"], 143 | download_url="https://edge.forgecdn.net/files/3276/129/fabric-carpet-1.16.5-1.4.32+v210414.jar", 144 | filename="fabric-carpet-1.16.5-1.4.32+v210414.jar", 145 | number="1.16.5-1.4.32+v210414", 146 | ), 147 | VersionInfo( 148 | stability=Stabilities.release, 149 | mod_loaders=set([ModLoaders.fabric, ModLoaders.forge]), 150 | site=Sites.curse, 151 | mod_name="Carpet", 152 | upload_time=1618425279, 153 | minecraft_versions=["1.17", "Fabric", "Forge"], 154 | download_url="https://edge.forgecdn.net/files/3276/130/fabric-carpet-21w15a-1.4.32+v210414.jar", 155 | filename="fabric-carpet-21w15a-1.4.32+v210414.jar", 156 | number="21w15a-1.4.32+v210414", 157 | ), 158 | ] 159 | 160 | actual = api.get_all_versions(mod()) 161 | 162 | verifyStubbedInvocationsAreUsed() 163 | unstub() 164 | 165 | assert expected == actual 166 | -------------------------------------------------------------------------------- /minecraft_mod_manager/gateways/api/mod_finder.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from typing import Dict, List, Optional 4 | 5 | from tealprint import TealPrint 6 | 7 | from ...core.entities.mod import Mod 8 | from ...core.entities.sites import Site, Sites 9 | from ...core.errors.mod_not_found_exception import ModNotFoundException 10 | from ..http import Http 11 | from .api import Api 12 | from .modrinth_api import ModrinthApi 13 | from .word_splitter_api import WordSplitterApi 14 | 15 | 16 | class ModFinder: 17 | """Search and find mods on various sites""" 18 | 19 | @staticmethod 20 | def create(http: Http) -> ModFinder: 21 | return ModFinder( 22 | mod_apis=[ModrinthApi(http)], 23 | word_splitter_api=WordSplitterApi(http), 24 | ) 25 | 26 | def __init__(self, mod_apis: List[Api], word_splitter_api: WordSplitterApi) -> None: 27 | self.apis = mod_apis 28 | self.word_splitter = word_splitter_api 29 | 30 | def find_mod(self, mod: Mod) -> Dict[Sites, Site]: 31 | """Find a mod. This differs from search in that it will only return found matches. 32 | It will also try with various search string until it finds a match. 33 | Throws an exception if no match is found.""" 34 | 35 | TealPrint.info("🔍 Searching for mod", push_indent=True) 36 | 37 | found_sites: Dict[Sites, Site] = {} 38 | 39 | # Already specified a site slug 40 | for api in self.apis: 41 | if mod.sites and api.site_name in mod.sites: 42 | existing_site = mod.sites[api.site_name] 43 | if existing_site.slug: 44 | TealPrint.verbose(f"🔍 Searching for {existing_site.slug} on {api.site_name}") 45 | infos = api.search_mod(existing_site.slug) 46 | for info in infos: 47 | if info.slug == existing_site.slug: 48 | TealPrint.verbose(f"🟢 Found {existing_site.slug} on {api.site_name}") 49 | found_sites[api.site_name] = info 50 | break 51 | 52 | if len(found_sites) > 0: 53 | TealPrint.pop_indent() 54 | return found_sites 55 | 56 | # Search by various possible slug names 57 | possible_names = mod.get_possible_slugs() 58 | for api in self.apis: 59 | TealPrint.verbose(f"🔍 Searching on {api.site_name}", push_indent=True) 60 | for possible_name in possible_names: 61 | TealPrint.debug(f"🔍 Search string: {possible_name}") 62 | infos = api.search_mod(possible_name) 63 | for info in infos: 64 | if info.slug in possible_names: 65 | TealPrint.debug(f"🟢 Found with slug: {info.slug}") 66 | found_sites[api.site_name] = info 67 | break 68 | if api.site_name in found_sites: 69 | break 70 | TealPrint.pop_indent() 71 | 72 | if len(found_sites) > 0: 73 | TealPrint.pop_indent() 74 | return found_sites 75 | 76 | # Split search word and try again 77 | split_word = self.word_splitter.split_words(mod.id) 78 | if split_word != mod.id: 79 | for api in self.apis: 80 | TealPrint.verbose(f"🔍 Searching on {api.site_name} by splitting word: {split_word}") 81 | infos = api.search_mod(split_word) 82 | for info in infos: 83 | if info.slug in possible_names: 84 | TealPrint.debug(f"🟢 Found with slug: {info.slug}") 85 | found_sites[api.site_name] = info 86 | break 87 | 88 | TealPrint.pop_indent() 89 | if len(found_sites) > 0: 90 | return found_sites 91 | 92 | raise ModNotFoundException(mod) 93 | 94 | def get_mod_info(self, site: Sites, site_id: str) -> Optional[Mod]: 95 | for api in self.apis: 96 | if api.site_name == site: 97 | return api.get_mod_info(site_id) 98 | 99 | return None 100 | -------------------------------------------------------------------------------- /minecraft_mod_manager/gateways/api/mod_finder_test.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from mockito import mock, unstub, when 3 | 4 | from ...core.entities.mod import Mod 5 | from ...core.entities.sites import Site, Sites 6 | from ...core.errors.mod_not_found_exception import ModNotFoundException 7 | from .api import Api 8 | from .mod_finder import ModFinder 9 | from .word_splitter_api import WordSplitterApi 10 | 11 | 12 | @pytest.fixture 13 | def curse(): 14 | mocked = mock(Api) 15 | mocked.site_name = Sites.curse # type: ignore 16 | yield mocked 17 | unstub() 18 | 19 | 20 | @pytest.fixture 21 | def modrinth(): 22 | mocked = mock(Api) 23 | mocked.site_name = Sites.modrinth # type: ignore 24 | yield mocked 25 | unstub() 26 | 27 | 28 | @pytest.fixture 29 | def word_splitter(): 30 | mocked = mock(WordSplitterApi) 31 | yield mocked 32 | unstub() 33 | 34 | 35 | @pytest.mark.parametrize( 36 | "name,mod,curse_results,modrinth_results,word_splitter_result,expected", 37 | [ 38 | ( 39 | "Find site by specified slug id", 40 | Mod(id="carpet", name="Carpet", sites={Sites.curse: Site(Sites.curse, "1", "carpet-slug")}), 41 | { 42 | "carpet-slug": [Site(Sites.curse, "1", "carpet-slug"), Site(Sites.curse, "2", "carpet")], 43 | }, 44 | {}, 45 | None, 46 | { 47 | Sites.curse: Site(Sites.curse, "1", "carpet-slug"), 48 | }, 49 | ), 50 | ( 51 | "Find both sites when specified by slug id", 52 | Mod( 53 | id="carpet", 54 | name="Carpet", 55 | sites={ 56 | Sites.curse: Site(Sites.curse, "1", "carpet-curse"), 57 | Sites.modrinth: Site(Sites.modrinth, "a", "carpet-modrinth"), 58 | }, 59 | ), 60 | { 61 | "carpet-curse": [Site(Sites.curse, "1", "carpet-curse"), Site(Sites.curse, "2", "carpet")], 62 | }, 63 | { 64 | "carpet-modrinth": [Site(Sites.modrinth, "a", "carpet"), Site(Sites.modrinth, "b", "carpet-modrinth")], 65 | }, 66 | None, 67 | { 68 | Sites.curse: Site(Sites.curse, "1", "carpet-curse"), 69 | Sites.modrinth: Site(Sites.modrinth, "b", "carpet-modrinth"), 70 | }, 71 | ), 72 | ( 73 | "Find both sites when using possible names", 74 | Mod(id="fabric-api", name="Fabric API"), 75 | { 76 | "fabric": [Site(Sites.curse, "1", "carpet-curse"), Site(Sites.curse, "2", "carpet")], 77 | "api": [Site(Sites.curse, "3", "fabric-api"), Site(Sites.curse, "4", "fubric-api")], 78 | "fabric-api": [Site(Sites.curse, "5", "febric"), Site(Sites.curse, "6", "fubric")], 79 | }, 80 | { 81 | "fabric": [Site(Sites.modrinth, "a", "carpet"), Site(Sites.modrinth, "b", "carpet-modrinth")], 82 | "api": [Site(Sites.modrinth, "c", "fabric"), Site(Sites.modrinth, "d", "fubric")], 83 | "fabric-api": [Site(Sites.modrinth, "e", "febric-api"), Site(Sites.modrinth, "f", "fubric-api")], 84 | }, 85 | None, 86 | { 87 | Sites.curse: Site(Sites.curse, "3", "fabric-api"), 88 | Sites.modrinth: Site(Sites.modrinth, "c", "fabric"), 89 | }, 90 | ), 91 | ( 92 | "Use wordsplitter to find id", 93 | Mod(id="fabricapi", name="Fabric API"), 94 | { 95 | "fabricapi": [Site(Sites.curse, "1", "carpet-curse"), Site(Sites.curse, "2", "carpet")], 96 | "fabric api": [Site(Sites.curse, "3", "fabricapi"), Site(Sites.curse, "4", "fubric-api")], 97 | }, 98 | { 99 | "fabricapi": [Site(Sites.modrinth, "a", "carpet"), Site(Sites.modrinth, "b", "carpet-modrinth")], 100 | "fabric api": [Site(Sites.modrinth, "c", "fabricapi"), Site(Sites.modrinth, "d", "fubric")], 101 | }, 102 | "fabric api", 103 | { 104 | Sites.curse: Site(Sites.curse, "3", "fabricapi"), 105 | Sites.modrinth: Site(Sites.modrinth, "c", "fabricapi"), 106 | }, 107 | ), 108 | ( 109 | "ModNotFoundException when mod couldn't be found to find id", 110 | Mod(id="fabricapi", name="Fabric API"), 111 | { 112 | "fabricapi": [Site(Sites.curse, "1", "carpet-curse"), Site(Sites.curse, "2", "carpet")], 113 | "fabric api": [Site(Sites.curse, "3", "fabric-api"), Site(Sites.curse, "4", "fubric-api")], 114 | }, 115 | { 116 | "fabricapi": [Site(Sites.modrinth, "a", "carpet"), Site(Sites.modrinth, "b", "carpet-modrinth")], 117 | "fabric api": [Site(Sites.modrinth, "c", "fabric-api"), Site(Sites.modrinth, "d", "fubric")], 118 | }, 119 | "fabric api", 120 | ModNotFoundException, 121 | ), 122 | ], 123 | ) 124 | def test_find_mod( 125 | name, mod, curse_results, modrinth_results, word_splitter_result, expected, curse, modrinth, word_splitter 126 | ): 127 | print(name) 128 | finder = ModFinder([curse, modrinth], word_splitter) 129 | 130 | # Stub curse 131 | for search_term, result in curse_results.items(): 132 | when(curse).search_mod(search_term).thenReturn(result) 133 | 134 | # Stub modrinth 135 | for search_term, result in modrinth_results.items(): 136 | when(modrinth).search_mod(search_term).thenReturn(result) 137 | 138 | # Stub word_splitter 139 | if word_splitter_result is not None: 140 | when(word_splitter).split_words(...).thenReturn(word_splitter_result) 141 | 142 | if expected == ModNotFoundException: 143 | with pytest.raises(ModNotFoundException): 144 | finder.find_mod(mod) 145 | else: 146 | actual = finder.find_mod(mod) 147 | assert expected == actual 148 | 149 | unstub() 150 | -------------------------------------------------------------------------------- /minecraft_mod_manager/gateways/api/modrinth_api.py: -------------------------------------------------------------------------------- 1 | from enum import Enum 2 | from typing import Any, Dict, List, Optional, Set 3 | 4 | from tealprint import TealPrint 5 | 6 | from ...config import config 7 | from ...core.entities.mod import Mod, ModArg 8 | from ...core.entities.mod_loaders import ModLoaders 9 | from ...core.entities.sites import Site, Sites 10 | from ...core.entities.version_info import Stabilities, VersionInfo 11 | from ...core.errors.mod_not_found_exception import ModNotFoundException 12 | from ..http import Http 13 | from .api import Api 14 | 15 | _base_url = "https://api.modrinth.com/api/v1" 16 | 17 | 18 | class DependencyTypes(Enum): 19 | required = "required" 20 | optional = "optional" 21 | incompatible = "incompatible" 22 | 23 | 24 | class ModrinthApi(Api): 25 | def __init__(self, http: Http) -> None: 26 | super().__init__(http, Sites.modrinth) 27 | 28 | def get_all_versions(self, mod: Mod) -> List[VersionInfo]: 29 | versions: List[VersionInfo] = [] 30 | json = self.http.get(ModrinthApi._make_versions_url(mod)) 31 | for json_version in json: 32 | try: 33 | version = ModrinthApi._json_to_version_info(json_version) 34 | version.dependencies = self._find_dependencies_for_version(json_version) 35 | version.name = mod.name 36 | versions.append(version) 37 | except IndexError: 38 | # Skip this version 39 | pass 40 | 41 | return versions 42 | 43 | @staticmethod 44 | def _make_versions_url(mod: Mod) -> str: 45 | if Sites.modrinth in mod.sites: 46 | return f"{_base_url}/mod/{mod.sites[Sites.modrinth].id}/version" 47 | raise RuntimeError("No site id found") 48 | 49 | def search_mod(self, search: str) -> List[Site]: 50 | sites: List[Site] = [] 51 | 52 | # Search by query 53 | mods = self._search_mod(search) 54 | 55 | # Get by mod slug 56 | try: 57 | mod = self.get_mod_info(search) 58 | mods.append(mod) 59 | except ModNotFoundException: 60 | # Not found by slug 61 | pass 62 | 63 | # Convert to site 64 | for mod in mods: 65 | sites.append(mod.sites[Sites.modrinth]) 66 | 67 | return sites 68 | 69 | def _search_mod(self, search: str) -> List[Mod]: 70 | mods: List[Mod] = [] 71 | json = self.http.get(ModrinthApi._make_search_url(search)) 72 | if "hits" in json: 73 | for mod_info in json["hits"]: 74 | if {"slug", "mod_id", "title"}.issubset(mod_info): 75 | slug = mod_info["slug"] 76 | site_id = str(mod_info["mod_id"]) 77 | site_id = site_id.replace("local-", "") 78 | name = mod_info["title"] 79 | mods.append(Mod(id="", name=name, sites={Sites.modrinth: Site(Sites.modrinth, site_id, slug)})) 80 | 81 | return mods 82 | 83 | @staticmethod 84 | def _make_search_url(search: str) -> str: 85 | filter = "" 86 | if config.filter.loader != ModLoaders.unknown: 87 | filter += f'&filters=categories="{config.filter.loader.value}"' 88 | if config.filter.version: 89 | filter += f'&version=versions="{config.filter.version}"' 90 | 91 | return f"{_base_url}/mod?query={search}{filter}" 92 | 93 | def get_mod_info(self, site_id: str) -> Mod: 94 | json = self.http.get(f"{_base_url}/mod/{site_id}") 95 | if {"id", "slug", "title"}.issubset(json): 96 | return Mod( 97 | id="", 98 | name=json["title"], 99 | sites={Sites.modrinth: Site(Sites.modrinth, str(json["id"]), json["slug"])}, 100 | ) 101 | raise ModNotFoundException(ModArg(site_id)) 102 | 103 | @staticmethod 104 | def _json_to_version_info(data: Any) -> VersionInfo: 105 | return VersionInfo( 106 | stability=Stabilities.from_name(data["version_type"]), 107 | mod_loaders=ModrinthApi._to_mod_loaders(data["loaders"]), 108 | site=Sites.modrinth, 109 | upload_time=Api._to_epoch_time(data["date_published"]), 110 | minecraft_versions=data["game_versions"], 111 | number=data["version_number"], 112 | download_url=data["files"][0]["url"], 113 | filename=data["files"][0]["filename"], 114 | ) 115 | 116 | def _find_dependencies_for_version(self, json_version: Any) -> Dict[Sites, List[str]]: 117 | dependencyVersions: List[str] = [] 118 | dependencyProjects: List[str] = [] 119 | 120 | for dependency in json_version["dependencies"]: 121 | if dependency["dependency_type"] == DependencyTypes.required.value: 122 | project_id = dependency["project_id"] 123 | version_id = dependency["version_id"] 124 | if project_id: 125 | dependencyProjects.append(project_id) 126 | elif version_id: 127 | dependencyVersions.append(version_id) 128 | else: 129 | TealPrint.debug(f"No project or version id found for dependency, {dependency}") 130 | 131 | # Get the actual project id from the version id 132 | for version_id in dependencyVersions: 133 | project_id = self._version_id_to_mod_id(version_id) 134 | if project_id: 135 | dependencyProjects.append(project_id) 136 | 137 | dependencyMap: Dict[Sites, List[str]] = {} 138 | if dependencyProjects: 139 | dependencyMap[Sites.modrinth] = dependencyProjects 140 | 141 | return dependencyMap 142 | 143 | def _version_id_to_mod_id(self, version_id: str) -> Optional[str]: 144 | json = self.http.get(f"{_base_url}/version/{version_id}") 145 | if json and "mod_id" in json: 146 | return str(json["mod_id"]) 147 | return "" 148 | 149 | @staticmethod 150 | def _to_mod_loaders(loaders: List[str]) -> Set[ModLoaders]: 151 | mod_loaders: Set[ModLoaders] = set() 152 | 153 | for loader in loaders: 154 | mod_loaders.add(ModLoaders.from_name(loader)) 155 | 156 | return mod_loaders 157 | -------------------------------------------------------------------------------- /minecraft_mod_manager/gateways/api/modrinth_api_test.py: -------------------------------------------------------------------------------- 1 | import json 2 | from pathlib import Path 3 | from typing import Optional 4 | 5 | import pytest 6 | from mockito import mock, unstub, verifyStubbedInvocationsAreUsed, when 7 | 8 | from ...core.entities.mod import Mod 9 | from ...core.entities.mod_loaders import ModLoaders 10 | from ...core.entities.sites import Site, Sites 11 | from ...core.entities.version_info import Stabilities, VersionInfo 12 | from ...core.errors.mod_not_found_exception import ModNotFoundException 13 | from ..http import Http 14 | from .modrinth_api import ModrinthApi, _base_url 15 | 16 | testdata_dir = Path(__file__).parent.joinpath("testdata").joinpath("modrinth_api") 17 | search_result_file = testdata_dir.joinpath("search_fabric-api.json") 18 | version_result_file = testdata_dir.joinpath("version.json") 19 | versions_result_file = testdata_dir.joinpath("versions_fabric-api.json") 20 | versions_without_files_file = testdata_dir.joinpath("versions-without-files.json") 21 | mod_info_file = testdata_dir.joinpath("mod_info.json") 22 | 23 | 24 | @pytest.fixture 25 | def search_result(): 26 | with open(search_result_file) as file: 27 | return json.load(file) 28 | 29 | 30 | @pytest.fixture 31 | def version_result(): 32 | with open(version_result_file) as file: 33 | return json.load(file) 34 | 35 | 36 | @pytest.fixture 37 | def versions_result(): 38 | with open(versions_result_file) as file: 39 | return json.load(file) 40 | 41 | 42 | @pytest.fixture 43 | def versions_without_files(): 44 | with open(versions_without_files_file) as file: 45 | return json.load(file) 46 | 47 | 48 | @pytest.fixture 49 | def mod_info(): 50 | with open(mod_info_file) as file: 51 | return json.load(file) 52 | 53 | 54 | @pytest.fixture 55 | def http(): 56 | mocked = mock(Http) 57 | yield mocked 58 | unstub() 59 | 60 | 61 | @pytest.fixture 62 | def api(http): 63 | return ModrinthApi(http) 64 | 65 | 66 | site_id = "P7dR8mSH" 67 | 68 | 69 | def mod(id="fabric-api", name="Fabric API", site_slug="fabric-api", site_id=site_id, file: Optional[str] = None): 70 | return Mod(id=id, name=name, sites={Sites.modrinth: Site(Sites.modrinth, site_id, site_slug)}, file=file) 71 | 72 | 73 | def test_search_mod(api: ModrinthApi, search_result, mod_info): 74 | when(api.http).get(ModrinthApi._make_search_url("search-slug")).thenReturn(search_result) 75 | when(api.http).get(_base_url + "/mod/search-slug").thenReturn(mod_info) 76 | expected = [ 77 | Site(Sites.modrinth, "P7dR8mSH", "fabric-api"), 78 | Site(Sites.modrinth, "720sJXM2", "bineclaims"), 79 | Site(Sites.modrinth, "iA9GjB4v", "BoxOfPlaceholders"), 80 | Site(Sites.modrinth, "ZfVQ3Rjs", "mealapi"), 81 | Site(Sites.modrinth, "MLYQ9VGP", "cardboard"), 82 | Site(Sites.modrinth, "meZK2DCX", "dawn"), 83 | Site(Sites.modrinth, "ssUbhMkL", "gravestones"), 84 | Site(Sites.modrinth, "BahnQObN", "chat-icon-api"), 85 | Site(Sites.modrinth, "oq1VV8nB", "splashesAPI"), 86 | Site(Sites.modrinth, "gno5mxtx", "grand-economy"), 87 | Site(Sites.modrinth, "aC3cM3Vq", "mouse-tweaks"), 88 | ] 89 | 90 | actual = api.search_mod("search-slug") 91 | 92 | verifyStubbedInvocationsAreUsed() 93 | unstub() 94 | 95 | assert expected == actual 96 | 97 | 98 | def test_search_mod_with_get_mod_info_for_slug(api: ModrinthApi, search_result): 99 | when(api.http).get(ModrinthApi._make_search_url("search-slug")).thenReturn(search_result) 100 | when(api.http).get(_base_url + "/mod/search-slug").thenReturn("") 101 | expected = [ 102 | Site(Sites.modrinth, "P7dR8mSH", "fabric-api"), 103 | Site(Sites.modrinth, "720sJXM2", "bineclaims"), 104 | Site(Sites.modrinth, "iA9GjB4v", "BoxOfPlaceholders"), 105 | Site(Sites.modrinth, "ZfVQ3Rjs", "mealapi"), 106 | Site(Sites.modrinth, "MLYQ9VGP", "cardboard"), 107 | Site(Sites.modrinth, "meZK2DCX", "dawn"), 108 | Site(Sites.modrinth, "ssUbhMkL", "gravestones"), 109 | Site(Sites.modrinth, "BahnQObN", "chat-icon-api"), 110 | Site(Sites.modrinth, "oq1VV8nB", "splashesAPI"), 111 | Site(Sites.modrinth, "gno5mxtx", "grand-economy"), 112 | ] 113 | 114 | actual = api.search_mod("search-slug") 115 | 116 | verifyStubbedInvocationsAreUsed() 117 | unstub() 118 | 119 | assert expected == actual 120 | 121 | 122 | def test_get_mod_info(api: ModrinthApi, mod_info): 123 | when(api.http).get(...).thenReturn(mod_info) 124 | expected = Mod( 125 | id="", 126 | name="Mouse Tweaks", 127 | sites={Sites.modrinth: Site(Sites.modrinth, "aC3cM3Vq", "mouse-tweaks")}, 128 | ) 129 | 130 | actual = api.get_mod_info("123") 131 | 132 | verifyStubbedInvocationsAreUsed() 133 | unstub() 134 | 135 | assert expected == actual 136 | 137 | 138 | def test_get_mod_info_not_found(api: ModrinthApi): 139 | when(api.http).get(...).thenReturn({"error": "not found"}) 140 | 141 | with pytest.raises(ModNotFoundException): 142 | api.get_mod_info("123") 143 | 144 | verifyStubbedInvocationsAreUsed() 145 | unstub() 146 | 147 | 148 | def test_get_all_versions_directly_when_we_have_mod_id(api: ModrinthApi, versions_result, version_result): 149 | when(api.http).get(f"https://api.modrinth.com/api/v1/mod/{site_id}/version").thenReturn(versions_result) 150 | when(api.http).get("https://api.modrinth.com/api/v1/version/UWMXoG0K").thenReturn(version_result) 151 | expected = [ 152 | VersionInfo( 153 | stability=Stabilities.beta, 154 | mod_loaders=set([ModLoaders.fabric]), 155 | site=Sites.modrinth, 156 | mod_name="Fabric API", 157 | upload_time=1618769767, 158 | minecraft_versions=["21w16a"], 159 | download_url="https://cdn.modrinth.com/data/P7dR8mSH/versions/0.33.0+1.17/fabric-api-0.33.0+1.17.jar", 160 | filename="fabric-api-0.33.0+1.17.jar", 161 | dependencies={Sites.modrinth: ["1338", "1337"]}, 162 | number="0.33.0+1.17", 163 | ), 164 | VersionInfo( 165 | stability=Stabilities.release, 166 | mod_loaders=set([]), 167 | site=Sites.modrinth, 168 | mod_name="Fabric API", 169 | upload_time=1618768763, 170 | minecraft_versions=["1.16.5"], 171 | download_url="https://cdn.modrinth.com/data/P7dR8mSH/versions/0.33.0+1.16/fabric-api-0.33.0+1.16.jar", 172 | filename="fabric-api-0.33.0+1.16.jar", 173 | number="0.33.0+1.16", 174 | ), 175 | VersionInfo( 176 | stability=Stabilities.alpha, 177 | mod_loaders=set([ModLoaders.forge]), 178 | site=Sites.modrinth, 179 | mod_name="Fabric API", 180 | upload_time=1618429021, 181 | minecraft_versions=["21w15a"], 182 | download_url="https://cdn.modrinth.com/data/P7dR8mSH/versions/0.32.9+1.17/fabric-api-0.32.9+1.17.jar", 183 | filename="fabric-api-0.32.9+1.17.jar", 184 | number="0.32.9+1.17", 185 | ), 186 | VersionInfo( 187 | stability=Stabilities.release, 188 | mod_loaders=set([ModLoaders.fabric, ModLoaders.forge]), 189 | site=Sites.modrinth, 190 | mod_name="Fabric API", 191 | upload_time=1618427403, 192 | minecraft_versions=["1.16.5"], 193 | download_url="https://cdn.modrinth.com/data/P7dR8mSH/versions/0.32.9+1.16/fabric-api-0.32.9+1.16.jar", 194 | filename="fabric-api-0.32.9+1.16.jar", 195 | number="0.32.9+1.16", 196 | ), 197 | ] 198 | 199 | actual = api.get_all_versions(mod()) 200 | 201 | verifyStubbedInvocationsAreUsed() 202 | unstub() 203 | 204 | assert expected == actual 205 | 206 | 207 | def test_get_versions_without_files(api: ModrinthApi, versions_without_files): 208 | when(api.http).get(...).thenReturn(versions_without_files) 209 | expected = [ 210 | VersionInfo( 211 | stability=Stabilities.release, 212 | mod_loaders=set([ModLoaders.fabric]), 213 | site=Sites.modrinth, 214 | mod_name="Fabric API", 215 | upload_time=1638379386, 216 | minecraft_versions=["1.18"], 217 | download_url="https://cdn.modrinth.com/data/Nz0RSWrF/versions/0.2.5/lazy-language-loader-0.2.5.jar", 218 | filename="lazy-language-loader-0.2.5.jar", 219 | number="0.2.5", 220 | ), 221 | VersionInfo( 222 | stability=Stabilities.release, 223 | mod_loaders=set([ModLoaders.fabric]), 224 | site=Sites.modrinth, 225 | mod_name="Fabric API", 226 | upload_time=1638297554, 227 | minecraft_versions=["1.18"], 228 | download_url="https://cdn.modrinth.com/data/Nz0RSWrF/versions/0.2.3/lazy-language-loader-0.2.3.jar", 229 | filename="lazy-language-loader-0.2.3.jar", 230 | number="0.2.3", 231 | ), 232 | ] 233 | 234 | actual = api.get_all_versions(mod()) 235 | 236 | verifyStubbedInvocationsAreUsed() 237 | unstub() 238 | 239 | assert expected == actual 240 | -------------------------------------------------------------------------------- /minecraft_mod_manager/gateways/api/testdata/curse_api/files_carpet.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "id": 2918924, 4 | "displayName": "fabric-carpet-20w13b-1.3.17+v200401.jar", 5 | "fileName": "fabric-carpet-20w13b-1.3.17+v200401.jar", 6 | "fileDate": "2020-04-02T02:27:02.687Z", 7 | "fileLength": 841027, 8 | "releaseType": 2, 9 | "fileStatus": 4, 10 | "downloadUrl": "https://edge.forgecdn.net/files/2918/924/fabric-carpet-20w13b-1.3.17+v200401.jar", 11 | "isAlternate": false, 12 | "alternateFileId": 0, 13 | "dependencies": [ 14 | { 15 | "addonId": 1337, 16 | "type": 3 17 | }, 18 | { 19 | "addonId": 1338, 20 | "type": 3 21 | }, 22 | { 23 | "addonId": 1339, 24 | "type": 1 25 | } 26 | ], 27 | "isAvailable": true, 28 | "modules": [ 29 | { 30 | "foldername": "LICENSE", 31 | "fingerprint": 1136524626 32 | }, 33 | { 34 | "foldername": "fabric.mod.json", 35 | "fingerprint": 3630346566 36 | }, 37 | { 38 | "foldername": "carpet.mixins.json", 39 | "fingerprint": 2661675182 40 | }, 41 | { 42 | "foldername": "assets", 43 | "fingerprint": 1509151940 44 | }, 45 | { 46 | "foldername": "fabric-carpet-refmap.json", 47 | "fingerprint": 2937790196 48 | }, 49 | { 50 | "foldername": "META-INF", 51 | "fingerprint": 2464251221 52 | }, 53 | { 54 | "foldername": "carpet", 55 | "fingerprint": 1118330692 56 | } 57 | ], 58 | "packageFingerprint": 2369833318, 59 | "gameVersion": ["1.16-Snapshot", "Forge"], 60 | "installMetadata": null, 61 | "serverPackFileId": null, 62 | "hasInstallScript": false, 63 | "gameVersionDateReleased": "2019-08-01T00:00:00Z", 64 | "gameVersionFlavor": null 65 | }, 66 | { 67 | "id": 2815968, 68 | "displayName": "fabric-carpet-1.14.4-1.2.0+v191024.jar", 69 | "fileName": "fabric-carpet-1.14.4-1.2.0+v191024.jar", 70 | "fileDate": "2019-10-25T03:54:48.237Z", 71 | "fileLength": 706773, 72 | "releaseType": 3, 73 | "fileStatus": 4, 74 | "downloadUrl": "https://edge.forgecdn.net/files/2815/968/fabric-carpet-1.14.4-1.2.0+v191024.jar", 75 | "isAlternate": false, 76 | "alternateFileId": 0, 77 | "dependencies": [ 78 | { 79 | "addonId": 123456, 80 | "type": 1 81 | } 82 | ], 83 | "isAvailable": true, 84 | "modules": [ 85 | { 86 | "foldername": "LICENSE", 87 | "fingerprint": 1136524626 88 | }, 89 | { 90 | "foldername": "fabric.mod.json", 91 | "fingerprint": 3839156203 92 | }, 93 | { 94 | "foldername": "carpet.mixins.json", 95 | "fingerprint": 2548482506 96 | }, 97 | { 98 | "foldername": "assets", 99 | "fingerprint": 859158650 100 | }, 101 | { 102 | "foldername": "fabric-carpet-refmap.json", 103 | "fingerprint": 1790362765 104 | }, 105 | { 106 | "foldername": "META-INF", 107 | "fingerprint": 2464251221 108 | }, 109 | { 110 | "foldername": "carpet", 111 | "fingerprint": 1284448042 112 | } 113 | ], 114 | "packageFingerprint": 1344501363, 115 | "gameVersion": ["1.14.4"], 116 | "installMetadata": null, 117 | "serverPackFileId": null, 118 | "hasInstallScript": false, 119 | "gameVersionDateReleased": "2008-03-01T06:00:00Z", 120 | "gameVersionFlavor": null 121 | }, 122 | { 123 | "id": 3276129, 124 | "displayName": "Carpet Mod v1.4.32 for 1.16.5", 125 | "fileName": "fabric-carpet-1.16.5-1.4.32+v210414.jar", 126 | "fileDate": "2021-04-14T18:33:58.09Z", 127 | "fileLength": 1255548, 128 | "releaseType": 1, 129 | "fileStatus": 4, 130 | "downloadUrl": "https://edge.forgecdn.net/files/3276/129/fabric-carpet-1.16.5-1.4.32+v210414.jar", 131 | "isAlternate": false, 132 | "alternateFileId": 0, 133 | "dependencies": [], 134 | "isAvailable": true, 135 | "modules": [ 136 | { 137 | "foldername": "LICENSE_fabric-carpet", 138 | "fingerprint": 1543082860 139 | }, 140 | { 141 | "foldername": "carpet.accesswidener", 142 | "fingerprint": 768960206 143 | }, 144 | { 145 | "foldername": "assets", 146 | "fingerprint": 1990599361 147 | }, 148 | { 149 | "foldername": "carpet.mixins.json", 150 | "fingerprint": 1701478558 151 | }, 152 | { 153 | "foldername": "fabric.mod.json", 154 | "fingerprint": 2495936969 155 | }, 156 | { 157 | "foldername": "fabric-carpet-refmap.json", 158 | "fingerprint": 3357129954 159 | }, 160 | { 161 | "foldername": "META-INF", 162 | "fingerprint": 2464251221 163 | }, 164 | { 165 | "foldername": "carpet", 166 | "fingerprint": 2017555921 167 | } 168 | ], 169 | "packageFingerprint": 2620795202, 170 | "gameVersion": ["Fabric", "1.16.5", "1.16.4"], 171 | "installMetadata": null, 172 | "serverPackFileId": null, 173 | "hasInstallScript": false, 174 | "gameVersionDateReleased": "2008-03-01T06:00:00Z", 175 | "gameVersionFlavor": null 176 | }, 177 | { 178 | "id": 3276130, 179 | "displayName": "Carpet Mod v1.4.32 for 1.17", 180 | "fileName": "fabric-carpet-21w15a-1.4.32+v210414.jar", 181 | "fileDate": "2021-04-14T18:34:39Z", 182 | "fileLength": 1263266, 183 | "releaseType": 1, 184 | "fileStatus": 4, 185 | "downloadUrl": "https://edge.forgecdn.net/files/3276/130/fabric-carpet-21w15a-1.4.32+v210414.jar", 186 | "isAlternate": false, 187 | "alternateFileId": 0, 188 | "dependencies": [], 189 | "isAvailable": true, 190 | "modules": [ 191 | { 192 | "foldername": "LICENSE_fabric-carpet", 193 | "fingerprint": 1543082860 194 | }, 195 | { 196 | "foldername": "carpet.accesswidener", 197 | "fingerprint": 768960206 198 | }, 199 | { 200 | "foldername": "assets", 201 | "fingerprint": 918338356 202 | }, 203 | { 204 | "foldername": "carpet.mixins.json", 205 | "fingerprint": 3695126439 206 | }, 207 | { 208 | "foldername": "fabric.mod.json", 209 | "fingerprint": 3493234223 210 | }, 211 | { 212 | "foldername": "fabric-carpet-refmap.json", 213 | "fingerprint": 4043310502 214 | }, 215 | { 216 | "foldername": "META-INF", 217 | "fingerprint": 2464251221 218 | }, 219 | { 220 | "foldername": "carpet", 221 | "fingerprint": 900466927 222 | } 223 | ], 224 | "packageFingerprint": 2014246976, 225 | "gameVersion": ["1.17", "Fabric", "Forge"], 226 | "installMetadata": null, 227 | "serverPackFileId": null, 228 | "hasInstallScript": false, 229 | "gameVersionDateReleased": "2008-03-01T06:00:00Z", 230 | "gameVersionFlavor": null 231 | } 232 | ] 233 | -------------------------------------------------------------------------------- /minecraft_mod_manager/gateways/api/testdata/modrinth_api/mod_info.json: -------------------------------------------------------------------------------- 1 | { 2 | "id": "aC3cM3Vq", 3 | "slug": "mouse-tweaks", 4 | "project_type": "mod", 5 | "team": "Gn6mAc1d", 6 | "title": "Mouse Tweaks", 7 | "description": "Enhances inventory management by adding various functions to the mouse buttons. ", 8 | "body": "Mouse Tweaks replaces the standard RMB dragging mechanic, adds two new LMB dragging mechanics and an ability to quickly move items with the scroll wheel.\n\n## Installation\n\n1.16.5 and later: install Minecraft Forge or Fabric.\n\n1.14.4 and later: install Minecraft Forge.\n\n1.12.2 and earlier: install either Minecraft Forge or LiteLoader (or both).\n\nPut the Mouse Tweaks jar-file into the mods folder in your .minecraft directory.\n\n
\n\n[**Mouse Tweaks API**](https://github.com/YaLTeR/MouseTweaks/tree/master/src/main/java/yalter/mousetweaks/api)\n\n
\n\nConfiguration file: `.minecraft/config/MouseTweaks.cfg`\n\n## Tweaks\n\n### RMB Tweak\n\nVery similar to the standard RMB dragging mechanic, with one difference: if you drag over a slot multiple times, an item will be put there multiple times. Replaces the standard mechanic if enabled.\n\n**Configuration setting:** `RMBTweak=1`\n\nHold your right mouse button:\n\n![](https://i.imgur.com/Uo7xF.png)\n\nDrag your mouse around the crafting grid:\n\n![](https://i.imgur.com/NCRED.png)\n\nYou can drag your mouse on top of existing items:\n\n![](https://i.imgur.com/6MQv6.png)\n\n### LMB Tweak (with item)\n\nLets you quickly pick up or move items of the same type.\n\n**Configuration setting:** `LMBTweakWithItem=1`\n\nHold your left mouse button to pick up an item:\n\n![](https://i.imgur.com/ziuGG.png?1)\n\nDrag your mouse across the inventory. Items of the same type will be picked up:\n\n![](https://i.imgur.com/JDjsE.png?2)\n\nHold shift and drag. Items of the same type will get \"shift-clicked\":\n\n![](https://i.imgur.com/YrvmT.png?2)\n\n### LMB Tweak (without item)\n\nQuickly move items into another inventory.\n\n**Configuration setting:** `LMBTweakWithoutItem=1`\n\nHold shift, then hold your left mouse button:\n\n*(Mouse cursor is not visible for some reason)*\n\n![](https://i.imgur.com/f9Ejp.png?1)\n\nDrag your mouse across the inventory. Items will get \"shift-clicked\":\n\n*(Mouse cursor is not visible for some reason)*\n\n![](https://i.imgur.com/qBu6k.png?2)\n\n### Wheel Tweak\n\nScroll to quickly move items between inventories. When you scroll down on an item stack, its items will be moved one by one. When you scroll up, items will be moved into it from another inventory.\n\n**Configuration setting:** `WheelTweak=1`\n\n**Configuration setting:** `WheelSearchOrder=1`\n\nWhen you scroll up, the mod will search for items from last to first (when this is set to 1) or from first to last (when this is set to 0).\n\n**Configuration setting:** `WheelScrollDirection=0`\n\nSet this to 1 to invert the default scroll actions. So, when set to 1, scrolling down will pull the items and scrolling up will push the items.\n\nSet this to 2 to enable the inventory position aware scrolling. Scrolling up will push the items into the other inventory if it's above the selected slot, or pull items from the other inventory if it's below the selected slot. Vice versa for scrolling down.\n\n### Obsolete / Removed Settings\n\nThese settings existed in older Mouse Tweaks versions but were removed since.\n\n**Configuration setting:** `OnTickMethodOrder=Forge, LiteLoader`\n\nMouse Tweaks can use multiple APIs for an OnTick method that it requires. You can use this setting to control the API it prefers. This shouldn't really matter at all. If a method isn't supported (for example, you don't have the API installed) the mod will proceed to check the next ones.\n\n**Configuration setting:** `ScrollHandling=0`\n\nToggles between \"smooth scrolling, minor issues\" (0) and \"non-smooth scrolling, no issues\" (1). When set to smooth scrolling, minor issues may be experienced such as scrolling \"through\" JEI or other mods. When set to non-smooth scrolling, those issues will not happen, but the scrolling will be a little non-smooth. Non-smooth scrolling works only with the Forge OnTick method.\n\nThis option is set to smooth scrolling by default because the aforementioned issues require rather specific conditions to trigger and aren't very impactful, while scrolling items is something you do all the time and want the experience to be as good as possible.\n\n## Compatibility\n\nMouse Tweaks is compatible with everything based on `GuiContainer` (as long as the behavior isn't changed too much).\n\nIf your GUI isn't based on `GuiContainer`, or if you want to improve compatibility (making Mouse Tweaks ignore some slot, for example), take a look at the [API documentation](https://github.com/YaLTeR/MouseTweaks/blob/2dc5bb108c2663f9a07b3a181483733a0274b41a/src/api/java/yalter/mousetweaks/api/IMTModGuiContainer3.java).\n\n## Modpacks\n\nFeel free to include Mouse Tweaks in modpacks.\n", 9 | "body_url": null, 10 | "published": "2021-03-20T15:55:49.895120Z", 11 | "updated": "2022-01-13T07:39:57.976381Z", 12 | "status": "approved", 13 | "moderator_message": null, 14 | "license": { 15 | "id": "bsd-3-clause", 16 | "name": "BSD 3-Clause", 17 | "url": "https://cdn.modrinth.com/licenses/bsd-3-clause.txt" 18 | }, 19 | "client_side": "required", 20 | "server_side": "unsupported", 21 | "downloads": 1000, 22 | "followers": 45, 23 | "categories": ["utility", "storage"], 24 | "versions": [ 25 | "7F7mEY80", 26 | "ILp7UQip", 27 | "q0CGibTg", 28 | "VmXwwpoa", 29 | "RNWLd5dh", 30 | "ZP9EDGxB", 31 | "Q3YILuAe", 32 | "EGSuwm1J" 33 | ], 34 | "icon_url": "https://cdn.modrinth.com/data/aC3cM3Vq/icon.jpg", 35 | "issues_url": "https://github.com/YaLTeR/MouseTweaks/issues", 36 | "source_url": "https://github.com/YaLTeR/MouseTweaks", 37 | "wiki_url": null, 38 | "discord_url": null, 39 | "donation_urls": [], 40 | "gallery": [] 41 | } 42 | -------------------------------------------------------------------------------- /minecraft_mod_manager/gateways/api/testdata/modrinth_api/version.json: -------------------------------------------------------------------------------- 1 | { 2 | "id": "UWMXoG0K", 3 | "mod_id": "1337", 4 | "author_id": "JZA4dW8o", 5 | "featured": false, 6 | "name": "[22w16b] Fabric API 0.51.2+1.19", 7 | "version_number": "0.51.2+1.19", 8 | "changelog": "- 22w16b (modmuss50, Player)\n", 9 | "changelog_url": null, 10 | "date_published": "2022-04-20T21:37:40.666022Z", 11 | "downloads": 262, 12 | "version_type": "beta", 13 | "files": [ 14 | { 15 | "hashes": { 16 | "sha1": "294d89214e1c5107316438b944da7e2db690638e", 17 | "sha512": "9aef659f62c4d2d377ffa7872139edb3dd7f19c88b677121e11643e8ce265fbfba8803981ff7d721e92c9aae111603858f0a40affb588158e3644014e588f011" 18 | }, 19 | "url": "https://cdn.modrinth.com/data/P7dR8mSH/versions/0.51.2+1.19/fabric-api-0.51.2%2B1.19.jar", 20 | "filename": "fabric-api-0.51.2+1.19.jar", 21 | "primary": false 22 | } 23 | ], 24 | "dependencies": [], 25 | "game_versions": ["22w16b"], 26 | "loaders": ["fabric"] 27 | } 28 | -------------------------------------------------------------------------------- /minecraft_mod_manager/gateways/api/testdata/modrinth_api/versions-without-files.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "id": "cbpDCPIJ", 4 | "mod_id": "Nz0RSWrF", 5 | "author_id": "Nrex64Q7", 6 | "featured": false, 7 | "name": "0.2.5 - Major fix!!", 8 | "version_number": "0.2.5", 9 | "changelog": "# 0.2.5\n### **NOTE: DO NOT DOWNLOAD THE DEV, SOURCES, OR SOURCES-DEV UNLESS YOU ARE A DEVELOPER!**\n## Additions\n```diff\n+ Fix loading on 1.18.x due to incorrectly specified minimum Minecraft version\n```\n## Links\n[Modrinth](https://modrinth.com/mod/lazy-language-loader)\n[CurseForge](https://www.curseforge.com/minecraft/mc-mods/lazy-language-loader)\n[Github](https://github.com/ChachyDev/lazy-language-loader)", 10 | "changelog_url": null, 11 | "date_published": "2021-12-01T17:23:06.412792Z", 12 | "downloads": 25, 13 | "version_type": "release", 14 | "files": [ 15 | { 16 | "hashes": { 17 | "sha1": "feb136c022099b73a7f28adc996ac83add7929e7", 18 | "sha512": "7b2ea0b7865fcd05168195ce22a207353ae4db11376a63e20038130196789cd54958d863e4ee3065f0164ffac6e4878f3bd0f2b079719db8ba9cf5cda3682c85" 19 | }, 20 | "url": "https://cdn.modrinth.com/data/Nz0RSWrF/versions/0.2.5/lazy-language-loader-0.2.5.jar", 21 | "filename": "lazy-language-loader-0.2.5.jar", 22 | "primary": false 23 | } 24 | ], 25 | "dependencies": [], 26 | "game_versions": [ 27 | "1.18" 28 | ], 29 | "loaders": ["fabric"] 30 | }, 31 | { 32 | "id": "gbbefmHf", 33 | "mod_id": "Nz0RSWrF", 34 | "author_id": "Nrex64Q7", 35 | "featured": false, 36 | "name": "0.2.4 - Bug fixes", 37 | "version_number": "0.2.4", 38 | "changelog": "# 0.2.4\n### **NOTE: DO NOT DOWNLOAD THE DEV, SOURCES, OR SOURCES-DEV UNLESS YOU ARE A DEVELOPER!**\n## Additions\n```diff\n+ Pull in patches from 0.2.2 (Accidentally removed)\n```\n## Links\n[Modrinth](https://modrinth.com/mod/lazy-language-loader)\n[CurseForge](https://www.curseforge.com/minecraft/mc-mods/lazy-language-loader)\n[Github](https://github.com/ChachyDev/lazy-language-loader)\n\n**DOWNLOAD REMOVED AS MOD LOADING IS BROKEN**", 39 | "changelog_url": null, 40 | "date_published": "2021-12-01T07:44:16.974297Z", 41 | "downloads": 17, 42 | "version_type": "release", 43 | "files": [], 44 | "dependencies": [], 45 | "game_versions": ["1.18"], 46 | "loaders": ["fabric"] 47 | }, 48 | { 49 | "id": "m8ve59qv", 50 | "mod_id": "Nz0RSWrF", 51 | "author_id": "Nrex64Q7", 52 | "featured": false, 53 | "name": "1.18 support!", 54 | "version_number": "0.2.3", 55 | "changelog": "# 0.2.3\n### **NOTE: DO NOT DOWNLOAD THE DEV, SOURCES, OR SOURCES-DEV UNLESS YOU ARE A DEVELOPER!**\n## Additions\n```diff\n+ Add 1.18 support\n```\n## Links\n[Modrinth](https://modrinth.com/mod/lazy-language-loader)\n[CurseForge](https://www.curseforge.com/minecraft/mc-mods/lazy-language-loader)\n[Github](https://github.com/ChachyDev/lazy-language-loader)", 56 | "changelog_url": null, 57 | "date_published": "2021-11-30T18:39:14.403963Z", 58 | "downloads": 17, 59 | "version_type": "release", 60 | "files": [ 61 | { 62 | "hashes": { 63 | "sha1": "4b903d6004a2b54a83df23529843fb378d603f23", 64 | "sha512": "c0a698232c552e479e8640a41b1d0c464c3e3420be53f146c0723cfcb1e1e56c0c5efd2673008844702a715ee9618b85264b294cdee72c6daa2086bb69e469af" 65 | }, 66 | "url": "https://cdn.modrinth.com/data/Nz0RSWrF/versions/0.2.3/lazy-language-loader-0.2.3.jar", 67 | "filename": "lazy-language-loader-0.2.3.jar", 68 | "primary": false 69 | } 70 | ], 71 | "dependencies": [], 72 | "game_versions": ["1.18"], 73 | "loaders": ["fabric"] 74 | } 75 | ] 76 | -------------------------------------------------------------------------------- /minecraft_mod_manager/gateways/api/testdata/modrinth_api/versions_fabric-api.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "id": "Bnw2XweM", 4 | "mod_id": "P7dR8mSH", 5 | "author_id": "JZA4dW8o", 6 | "featured": false, 7 | "name": "[21w15a] Fabric API 0.33.0", 8 | "version_number": "0.33.0+1.17", 9 | "changelog": "The project has been updated to root project 'fabric-api'. No changelog was specified.", 10 | "changelog_url": null, 11 | "date_published": "2021-04-18T18:16:06.881834Z", 12 | "downloads": 4, 13 | "version_type": "beta", 14 | "files": [ 15 | { 16 | "hashes": { 17 | "sha512": "1f279b8b3355b2bb43db30bcf265e08826584e14b2af7abb3af64364059e6e1bef261f529c378474e4536eccd41c30aaea98bb39cfa02ec7b0ab421ebfa0f724", 18 | "sha1": "78eddaaaa4c6375db8cdfd8c586dac90c70acb99" 19 | }, 20 | "url": "https://cdn.modrinth.com/data/P7dR8mSH/versions/0.33.0+1.17/fabric-api-0.33.0+1.17.jar", 21 | "filename": "fabric-api-0.33.0+1.17.jar", 22 | "primary": false 23 | } 24 | ], 25 | "dependencies": [ 26 | { 27 | "version_id": "UWMXoG0K", 28 | "project_id": null, 29 | "dependency_type": "required" 30 | }, 31 | { 32 | "version_id": null, 33 | "project_id": null, 34 | "dependency_type": "required" 35 | }, 36 | { 37 | "version_id": "abc", 38 | "project_id": null, 39 | "dependency_type": "optional" 40 | }, 41 | { 42 | "version_id": "aoeu", 43 | "project_id": null, 44 | "dependency_type": "incompatible" 45 | }, 46 | { 47 | "version_id": null, 48 | "project_id": "1338", 49 | "dependency_type": "required" 50 | } 51 | ], 52 | "game_versions": ["21w16a"], 53 | "loaders": ["fabric"] 54 | }, 55 | { 56 | "id": "zd2RW4Xi", 57 | "mod_id": "P7dR8mSH", 58 | "author_id": "JZA4dW8o", 59 | "featured": false, 60 | "name": "[1.16.5] Fabric API 0.33.0", 61 | "version_number": "0.33.0+1.16", 62 | "changelog": "The project has been updated to root project 'fabric-api'. No changelog was specified.", 63 | "changelog_url": null, 64 | "date_published": "2021-04-18T17:59:23.016702Z", 65 | "downloads": 39, 66 | "version_type": "release", 67 | "files": [ 68 | { 69 | "hashes": { 70 | "sha512": "7b00747ddb3b5cadb48386fc2969ba295474c0d53a84cad227366fe56d2d3878590e5379462465cffe3447066b1fe32c35e2694686b22ec1615e513ed67875e6", 71 | "sha1": "7f20e318d9f244cbb7d0189b1c0103cb3f033969" 72 | }, 73 | "url": "https://cdn.modrinth.com/data/P7dR8mSH/versions/0.33.0+1.16/fabric-api-0.33.0+1.16.jar", 74 | "filename": "fabric-api-0.33.0+1.16.jar", 75 | "primary": false 76 | } 77 | ], 78 | "dependencies": [], 79 | "game_versions": ["1.16.5"], 80 | "loaders": [] 81 | }, 82 | { 83 | "id": "3XrQEeEu", 84 | "mod_id": "P7dR8mSH", 85 | "author_id": "JZA4dW8o", 86 | "featured": false, 87 | "name": "[21w15a] Fabric API 0.32.9", 88 | "version_number": "0.32.9+1.17", 89 | "changelog": "The project has been updated to root project 'fabric-api'. No changelog was specified.", 90 | "changelog_url": null, 91 | "date_published": "2021-04-14T19:37:00.630630Z", 92 | "downloads": 12, 93 | "version_type": "alpha", 94 | "files": [ 95 | { 96 | "hashes": { 97 | "sha1": "c94cd5f1d58a9415c64857d1e3760695bf8e948f", 98 | "sha512": "15b54ce72943e1b51e901c641d5887aa836e3d7ea07cdb03af722e2360d32e97d4fa51267ad811d1f86b1ef05bd63d2eca5b08f6dc2ffd9724e3199284c918d2" 99 | }, 100 | "url": "https://cdn.modrinth.com/data/P7dR8mSH/versions/0.32.9+1.17/fabric-api-0.32.9+1.17.jar", 101 | "filename": "fabric-api-0.32.9+1.17.jar", 102 | "primary": false 103 | } 104 | ], 105 | "dependencies": [], 106 | "game_versions": ["21w15a"], 107 | "loaders": ["forge"] 108 | }, 109 | { 110 | "id": "CI09738V", 111 | "mod_id": "P7dR8mSH", 112 | "author_id": "JZA4dW8o", 113 | "featured": false, 114 | "name": "[1.16.5] Fabric API 0.32.9", 115 | "version_number": "0.32.9+1.16", 116 | "changelog": "The project has been updated to root project 'fabric-api'. No changelog was specified.", 117 | "changelog_url": null, 118 | "date_published": "2021-04-14T19:10:02.767932Z", 119 | "downloads": 90, 120 | "version_type": "release", 121 | "files": [ 122 | { 123 | "hashes": { 124 | "sha512": "1ade2606a13c2ec4fa67d7eafbcbf5e6a6a6f48ca9d29831334ed10f4b5cbbcb7e9de157ac5bd73cf36fc1f3431787d34d57eaa74bff34c11265648687b805f9", 125 | "sha1": "230936e18384bfb5ba7f229f2b9776a6b56c5add" 126 | }, 127 | "url": "https://cdn.modrinth.com/data/P7dR8mSH/versions/0.32.9+1.16/fabric-api-0.32.9+1.16.jar", 128 | "filename": "fabric-api-0.32.9+1.16.jar", 129 | "primary": false 130 | } 131 | ], 132 | "dependencies": [], 133 | "game_versions": ["1.16.5"], 134 | "loaders": ["fabric", "forge"] 135 | } 136 | ] 137 | -------------------------------------------------------------------------------- /minecraft_mod_manager/gateways/api/word_splitter_api.py: -------------------------------------------------------------------------------- 1 | from ..http import Http 2 | 3 | _base_url = "https://word-splitter-5p3pi6z2ma-ew.a.run.app" 4 | 5 | 6 | class WordSplitterApi: 7 | def __init__(self, http: Http) -> None: 8 | self.http = http 9 | 10 | def split_words(self, text: str) -> str: 11 | return self.http.get(f"{_base_url}/{text}") 12 | -------------------------------------------------------------------------------- /minecraft_mod_manager/gateways/arg_parser.py: -------------------------------------------------------------------------------- 1 | import argparse 2 | from os import path 3 | from typing import Any, Dict, List, Union 4 | 5 | from tealprint import TealPrint 6 | 7 | from ..config import config 8 | from ..core.entities.actions import Actions 9 | from ..core.entities.mod import ModArg 10 | from ..core.entities.sites import Site, Sites 11 | from ..utils.log_colors import LogColors 12 | 13 | 14 | def parse_args(): 15 | parser = argparse.ArgumentParser(description="Install or update Minecraft mods from Modrinth and CurseForge") 16 | 17 | parser.add_argument( 18 | "action", 19 | choices=Actions.get_all_names_as_list(), 20 | help="What you want to do. version prints the version of this application", 21 | ) 22 | parser.add_argument( 23 | "mods", 24 | nargs="*", 25 | help="The mods to install, update, or configure. " 26 | + "If no mods are specified during an update, all mods will be updated. " 27 | + "To specify the download site for the mod you can put '=site' after the mod. " 28 | + "E.g. 'litematica=curse'. By default it searches all sites for the mod. " 29 | + "To configure an slug for the mod, use 'mod_name=curse:SLUG'. E.g. 'dynmap=curse:dynmapforge' " 30 | + "To reset configuration, type 'mod_name='. To specify multiple sites add a comma between site names. " 31 | + "E.g. 'dynmap=curse,modrinth' or 'dynmap=curse:dynmapforge,modrinth' if you want to have different slugs", 32 | ) 33 | parser.add_argument( 34 | "-d", 35 | "--dir", 36 | type=_is_dir, 37 | help="Location of the mods folder. By default it's the current directory", 38 | ) 39 | parser.add_argument( 40 | "-v", 41 | "--minecraft-version", 42 | help="Only update mods to this Minecraft version", 43 | ) 44 | parser.add_argument( 45 | "--beta", 46 | action="store_true", 47 | help="Allow beta releases of mods", 48 | ) 49 | parser.add_argument( 50 | "--alpha", 51 | action="store_true", 52 | help="Allow alpha and beta releases of mods", 53 | ) 54 | parser.add_argument( 55 | "--mod-loader", 56 | choices=["fabric", "forge"], 57 | help="Only install mods that use this mod loader. " 58 | + "You rarely need to be this specific. " 59 | + "The application figures out for itself which type you'll likely want to install.", 60 | ) 61 | parser.add_argument( 62 | "--verbose", 63 | action="store_true", 64 | help="Print more messages", 65 | ) 66 | parser.add_argument( 67 | "--debug", 68 | action="store_true", 69 | help="Turn on debug messages. This automatically turns on --verbose as well", 70 | ) 71 | parser.add_argument( 72 | "--pretend", 73 | action="store_true", 74 | help="Only pretend to install/update/configure. Does not change anything", 75 | ) 76 | parser.add_argument( 77 | "--version", 78 | action="version", 79 | version=f"{config.app_name}: {config.app_version}", 80 | help="Show application version", 81 | ) 82 | parser.add_argument( 83 | "--no-color", 84 | action="store_true", 85 | help="Disable color output", 86 | ) 87 | 88 | args = parser.parse_args() 89 | args.action = Actions.from_name(args.action) 90 | args.mods = _parse_mods(args.mods) 91 | return args 92 | 93 | 94 | def _parse_mods(args_mod: Any) -> List[ModArg]: 95 | mods: List[ModArg] = [] 96 | for mod_arg in args_mod: 97 | mod_arg = str(mod_arg) 98 | mod = ModArg("") 99 | if "=" in mod_arg: 100 | if mod_arg.count("=") > 1: 101 | _print_invalid_mod_syntax(mod_arg, "Too many equal signs '=' in argument") 102 | mod.id, sites = mod_arg.split("=") 103 | mod.sites = _parse_sites(mod_arg, sites) 104 | else: 105 | mod.id = mod_arg 106 | mods.append(mod) 107 | 108 | return mods 109 | 110 | 111 | def _parse_sites(mod_arg, sites: str) -> Dict[Sites, Site]: 112 | if "," in sites: 113 | sites_str = sites.split(",") 114 | else: 115 | sites_str = [sites] 116 | 117 | sites_dict: Dict[Sites, Site] = {} 118 | for site_str in sites_str: 119 | site = _parse_site(mod_arg, site_str) 120 | if site: 121 | sites_dict[site.name] = site 122 | return sites_dict 123 | 124 | 125 | def _parse_site(mod_arg: str, site_str: str) -> Union[Site, None]: 126 | if len(site_str) == 0: 127 | return None 128 | 129 | slug: Union[str, None] = None 130 | name_str: str = "" 131 | 132 | # Slug 133 | if ":" in site_str: 134 | if site_str.count(":") > 1: 135 | _print_invalid_mod_syntax(mod_arg, "Too many colon signs ':' in argument") 136 | name_str, slug = site_str.split(":") 137 | else: 138 | name_str = site_str 139 | 140 | try: 141 | name = Sites[name_str] 142 | return Site(name, slug=slug) 143 | except KeyError: 144 | _print_invalid_mod_syntax(mod_arg, f"Invalid site, valid sites are {Sites.all()}") 145 | return None 146 | 147 | 148 | def _print_invalid_mod_syntax(mod_arg: str, extra_info: str) -> None: 149 | TealPrint.error(f"Invalid mod syntax: {mod_arg}") 150 | TealPrint.error(extra_info) 151 | TealPrint.error( 152 | f"Valid syntax is: {LogColors.command}dynmap=curse{LogColors.no_color}, " 153 | + f"{LogColors.command}dynmap=curse:dynmapforge{LogColors.no_color}, or " 154 | + f"{LogColors.command}dynmap=modrinth,curse:dynmapforge{LogColors.no_color}", 155 | exit=True, 156 | ) 157 | 158 | 159 | def _is_dir(dir: str) -> str: 160 | if path.isdir(dir): 161 | return dir 162 | else: 163 | raise NotADirectoryError(dir) 164 | -------------------------------------------------------------------------------- /minecraft_mod_manager/gateways/arg_parser_test.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from ..core.entities.mod import ModArg 4 | from ..core.entities.sites import Site, Sites 5 | from .arg_parser import _parse_mods 6 | 7 | 8 | @pytest.mark.parametrize( 9 | "name,input,expected", 10 | [ 11 | ( 12 | "Only mod ids", 13 | [ 14 | "carpet", 15 | "litematica", 16 | ], 17 | [ 18 | ModArg("carpet"), 19 | ModArg("litematica"), 20 | ], 21 | ), 22 | ( 23 | "Valid repo types", 24 | [ 25 | "carpet=curse", 26 | "litematica=modrinth", 27 | ], 28 | [ 29 | ModArg("carpet", {Sites.curse: Site(Sites.curse)}), 30 | ModArg("litematica", {Sites.modrinth: Site(Sites.modrinth)}), 31 | ], 32 | ), 33 | ( 34 | "Valid slugs", 35 | [ 36 | "carpet=curse:fabric-carpet", 37 | "litematica=modrinth:litematica-fabric", 38 | ], 39 | [ 40 | ModArg("carpet", {Sites.curse: Site(Sites.curse, slug="fabric-carpet")}), 41 | ModArg("litematica", {Sites.modrinth: Site(Sites.modrinth, slug="litematica-fabric")}), 42 | ], 43 | ), 44 | ( 45 | "Reset site", 46 | ["carpet="], 47 | [ModArg("carpet", sites={})], 48 | ), 49 | ( 50 | "Multiple sites for one mod", 51 | ["carpet=curse,modrinth"], 52 | [ 53 | ModArg( 54 | "carpet", 55 | sites={ 56 | Sites.curse: Site(Sites.curse), 57 | Sites.modrinth: Site(Sites.modrinth), 58 | }, 59 | ) 60 | ], 61 | ), 62 | ( 63 | "Multiple sites and slugs for one mod", 64 | ["carpet=curse:fabric-carpet,modrinth:fabric-carpet"], 65 | [ 66 | ModArg( 67 | "carpet", 68 | sites={ 69 | Sites.curse: Site(Sites.curse, slug="fabric-carpet"), 70 | Sites.modrinth: Site(Sites.modrinth, slug="fabric-carpet"), 71 | }, 72 | ) 73 | ], 74 | ), 75 | ( 76 | "Invalid repo", 77 | [ 78 | "carpet=hello", 79 | ], 80 | SystemExit, 81 | ), 82 | ], 83 | ) 84 | def test_parse_mods(name, input, expected): 85 | print(name) 86 | 87 | # Expected pass 88 | if type(expected) is list: 89 | result = _parse_mods(input) 90 | assert expected == result 91 | 92 | # Expected error 93 | if type(expected) is SystemExit: 94 | with pytest.raises(SystemExit) as e: 95 | _parse_mods(input) 96 | assert expected == e.type 97 | -------------------------------------------------------------------------------- /minecraft_mod_manager/gateways/http.py: -------------------------------------------------------------------------------- 1 | import re 2 | import time 3 | from os import path 4 | from typing import Any, Dict 5 | 6 | import latest_user_agents 7 | import requests 8 | from requests.models import Response 9 | from tealprint import TealPrint 10 | 11 | from ..config import config 12 | from ..core.errors.download_failed import DownloadFailed 13 | 14 | _headers = {"User-Agent": latest_user_agents.get_random_user_agent()} 15 | 16 | 17 | class MaxRetriesExceeded(Exception): 18 | def __init__(self, url: str, retries: int, status_code: int, reason: str, content: str): 19 | self.url = url 20 | self.retries = retries 21 | self.status_code = status_code 22 | self.reason = reason 23 | self.content = content 24 | 25 | def __str__(self) -> str: 26 | return f"{self.status_code}: {self.reason}" 27 | 28 | 29 | class Http: 30 | retries_max = 5 31 | retry_backoff_factor = 1.5 32 | 33 | def __init__(self) -> None: 34 | self.cache: Dict[str, Any] = {} 35 | 36 | def get(self, url: str) -> Any: 37 | if url in self.cache: 38 | return self.cache[url] 39 | 40 | with Http._get_with_retries(url) as response: 41 | content_type = response.headers.get("Content-Type", "plain/text") 42 | # Check if headers is json 43 | if content_type.startswith("application/json"): 44 | self.cache[url] = response.json(strict=False) 45 | else: 46 | self.cache[url] = response.text 47 | return self.cache[url] 48 | 49 | def download(self, url: str, filename: str) -> str: 50 | """Download the specified mod 51 | Returns: 52 | Filename of the downloaded and saved file 53 | Exception: 54 | DownloadFailed if the download failed 55 | """ 56 | 57 | if config.pretend: 58 | return filename 59 | 60 | with Http._get_with_retries(url) as response: 61 | if response.status_code != 200: 62 | raise DownloadFailed(response.status_code, response.reason, str(response.content)) 63 | 64 | if len(filename) == 0: 65 | filename = Http._get_filename(response) 66 | 67 | if not filename.endswith(".jar"): 68 | filename += ".jar" 69 | 70 | filename = path.join(config.dir, filename) 71 | 72 | # Save file 73 | with open(filename, "wb") as file: 74 | file.write(response.content) 75 | 76 | return filename 77 | 78 | @staticmethod 79 | def _get_with_retries(url: str) -> Response: 80 | response: Response = Response() 81 | for retry in range(Http.retries_max): 82 | response = requests.get(url, headers=_headers) 83 | if response.status_code < 500 or response.status_code >= 600: 84 | return response 85 | elif retry < Http.retries_max: 86 | response.close() 87 | delay = Http.retry_backoff_factor**retry 88 | TealPrint.warning(f"{(retry+1)}: Failed to connect to {url}. Retrying in {delay} seconds...") 89 | time.sleep(delay) 90 | TealPrint.error(f"{Http.retries_max}: Failed to connect to {url}. Giving up.") 91 | TealPrint.error(f"{response.status_code}: {response.reason}") 92 | raise MaxRetriesExceeded(url, Http.retries_max, response.status_code, response.reason, str(response.content)) 93 | 94 | @staticmethod 95 | def _get_filename(response: Response) -> str: 96 | content_disposition = response.headers.get("content-disposition") 97 | if not content_disposition: 98 | return "" 99 | 100 | filename = re.search(r"filename=(.+)", content_disposition) 101 | if not filename: 102 | return "" 103 | 104 | return filename.group(1) 105 | -------------------------------------------------------------------------------- /minecraft_mod_manager/gateways/http_test.py: -------------------------------------------------------------------------------- 1 | import builtins 2 | from io import FileIO 3 | from os import path 4 | 5 | import pytest 6 | import requests 7 | from mockito import mock, unstub, when 8 | from requests.models import Response 9 | 10 | from ..config import config 11 | from ..core.errors.download_failed import DownloadFailed 12 | from .http import Http, MaxRetriesExceeded 13 | 14 | 15 | @pytest.fixture 16 | def response(): 17 | response = Response() 18 | response.status_code = 200 19 | response.encoding = "UTF-8" 20 | response._content = b"" # type:ignore 21 | return response 22 | 23 | 24 | @pytest.fixture 25 | def mock_response(): 26 | response = mock(Response) 27 | response.status_code = 200 # type:ignore 28 | response.content = "" # type:ignore 29 | when(response).__enter__(...).thenReturn(response) 30 | when(response).__exit__(...) 31 | return response 32 | 33 | 34 | @pytest.fixture 35 | def mock_file(): 36 | mock_file = mock(FileIO) 37 | when(mock_file).write(...).thenReturn(0) 38 | when(mock_file).__enter__(...).thenReturn(mock_file) 39 | when(mock_file).__exit__(...) 40 | return mock_file 41 | 42 | 43 | @pytest.fixture 44 | def http(): 45 | return Http() 46 | 47 | 48 | def test_use_filename_when_it_exists(http, response, mock_file): 49 | filename = "some-file.jar" 50 | expected = path.join(config.dir, filename) 51 | 52 | when(requests).get(...).thenReturn(response) 53 | when(builtins).open(...).thenReturn(mock_file) 54 | 55 | actual = http.download("", filename) 56 | 57 | unstub() 58 | assert expected == actual 59 | 60 | 61 | def test_use_downloaded_filename_when_no_filename_specified(http, response, mock_file): 62 | filename = "downloaded.jar" 63 | expected = path.join(config.dir, filename) 64 | response.headers["Content-Disposition"] = f"attachment; filename={filename}" 65 | when(requests).get(...).thenReturn(response) 66 | when(builtins).open(...).thenReturn(mock_file) 67 | 68 | actual = http.download("", "") 69 | 70 | unstub() 71 | assert expected == actual 72 | 73 | 74 | def test_use_downloaded_filename_add_jar_when_no_filename_specified(http, response, mock_file): 75 | filename = "downloaded.jar" 76 | expected = path.join(config.dir, filename) 77 | response.headers["Content-Disposition"] = f"attachment; filename={filename}" 78 | when(requests).get(...).thenReturn(response) 79 | when(builtins).open(...).thenReturn(mock_file) 80 | 81 | actual = http.download("", "") 82 | 83 | unstub() 84 | assert expected == actual 85 | 86 | 87 | def test_no_mock_interactions_when_pretending(http): 88 | filename = "file.jar" 89 | expected = filename 90 | config.pretend = True 91 | when(requests).get(...).thenRaise(NotImplementedError()) 92 | when(builtins).open(...).thenRaise(NotImplementedError()) 93 | 94 | try: 95 | actual = http.download("", filename) 96 | assert expected == actual 97 | except Exception as e: 98 | assert e is None 99 | finally: 100 | config.pretend = False 101 | unstub() 102 | 103 | 104 | def test_get_when_non_strict_json(http, response): 105 | response.headers["Content-Type"] = "application/json" 106 | response._content = '\n{"text":"This is\nmy text"}'.encode("UTF-8") 107 | expected = {"text": "This is\nmy text"} 108 | when(requests).get(...).thenReturn(response) 109 | 110 | actual = http.get("https://test.com") 111 | 112 | assert expected == actual 113 | 114 | unstub() 115 | 116 | 117 | def test_get_when_response_is_string(http, response): 118 | response.headers["Content-Type"] = "text/plain" 119 | expected = "This is my text" 120 | response._content = b"This is my text" # type:ignore 121 | when(requests).get(...).thenReturn(response) 122 | 123 | actual = http.get("https://test.com") 124 | 125 | assert expected == actual 126 | 127 | 128 | def test_get_when_content_type_is_missing(http, response): 129 | expected = "This is my text" 130 | response._content = b"This is my text" # type:ignore 131 | when(requests).get(...).thenReturn(response) 132 | 133 | actual = http.get("https://test.com") 134 | 135 | assert expected == actual 136 | 137 | 138 | def test_download_failed(http, response): 139 | response.status_code = 404 # type:ignore 140 | response.reason = "Not found" # type:ignore 141 | response._content = "404 not found" # type:ignore 142 | when(requests).get(...).thenReturn(response) 143 | 144 | with pytest.raises(DownloadFailed): 145 | http.download("", "") 146 | 147 | unstub() 148 | 149 | 150 | def test_download_retry(http, mock_response): 151 | mock_response.status_code = 524 # type:ignore 152 | mock_response.reason = "Timed out" # type:ignore 153 | mock_response._content = "524 Timed out" # type:ignore 154 | when(mock_response).close(...) 155 | when(requests).get(...).thenReturn(mock_response) 156 | 157 | with pytest.raises(MaxRetriesExceeded): 158 | http.download("", "") 159 | 160 | unstub() 161 | -------------------------------------------------------------------------------- /minecraft_mod_manager/gateways/jar_parser.py: -------------------------------------------------------------------------------- 1 | import json 2 | import re 3 | from pathlib import Path 4 | from typing import Any, List, MutableMapping, Union 5 | from zipfile import ZipFile 6 | 7 | import toml 8 | from tealprint import TealPrint 9 | 10 | from ..core.entities.mod import Mod 11 | from ..core.entities.mod_loaders import ModLoaders 12 | from ..core.errors.mod_file_invalid import ModFileInvalid 13 | from ..utils.log_colors import LogColors 14 | 15 | 16 | class JarParser: 17 | _fabric_file = "fabric.mod.json" 18 | _forge_file = "META-INF/mods.toml" 19 | 20 | def __init__(self, dir: Path) -> None: 21 | self._mods: List[Mod] = [] 22 | self._dir = dir 23 | 24 | @property 25 | def mods(self) -> List[Mod]: 26 | # Return cached value 27 | if len(self._mods) > 0: 28 | return self._mods 29 | 30 | # Iterate through all files 31 | for file in self._dir.glob("*.jar"): 32 | TealPrint.debug(f"Found file {file}") 33 | try: 34 | mod = JarParser._get_mod_info(file) 35 | if mod: 36 | JarParser._log_found_mod(mod) 37 | self._mods.append(mod) 38 | except ModFileInvalid as e: 39 | TealPrint.warning(str(e)) 40 | 41 | return self._mods 42 | 43 | def get_mod(self, file: str) -> Union[Mod, None]: 44 | return JarParser._get_mod_info(self._dir.joinpath(file)) 45 | 46 | @staticmethod 47 | def _get_mod_info(file: Path) -> Union[Mod, None]: 48 | mod: Union[Mod, None] = None 49 | 50 | try: 51 | with ZipFile(file, "r") as zip: 52 | if JarParser._is_fabric(zip): 53 | mod = JarParser._parse_fabric(zip) 54 | elif JarParser._is_forge(zip): 55 | mod = JarParser._parse_forge(zip) 56 | else: 57 | TealPrint.warning(f"No mod info found for {file.name}") 58 | except Exception: 59 | TealPrint.error(f"Failed to parse mod file {file}", print_exception=True, print_report_this=True) 60 | raise ModFileInvalid(file) 61 | 62 | if mod: 63 | mod.file = file.name 64 | return mod 65 | 66 | return None 67 | 68 | @staticmethod 69 | def _is_fabric(zip: ZipFile) -> bool: 70 | return JarParser._fabric_file in zip.namelist() 71 | 72 | @staticmethod 73 | def _parse_fabric(zip: ZipFile) -> Mod: 74 | with zip.open(JarParser._fabric_file) as json_file: 75 | full_doc = json_file.read().decode("utf-8", "ignore") 76 | object = json.loads(full_doc, strict=False) 77 | return Mod( 78 | id=object["id"], 79 | name=object["name"], 80 | version=object["version"], 81 | mod_loader=ModLoaders.fabric, 82 | ) 83 | 84 | @staticmethod 85 | def _is_forge(zip: ZipFile) -> bool: 86 | return JarParser._forge_file in zip.namelist() 87 | 88 | @staticmethod 89 | def _parse_forge(zip: ZipFile) -> Mod: 90 | with zip.open(JarParser._forge_file) as file: 91 | full_doc = file.read().decode("utf-8", "ignore") 92 | obj = JarParser._load_toml(full_doc) 93 | mods = obj["mods"][0] 94 | return Mod( 95 | mods["modId"], 96 | mods["displayName"], 97 | mod_loader=ModLoaders.forge, 98 | version=mods["version"], 99 | ) 100 | 101 | @staticmethod 102 | def _load_toml(toml_str: str) -> MutableMapping[str, Any]: 103 | try: 104 | return toml.loads(toml_str) 105 | except toml.TomlDecodeError: 106 | return toml.loads(JarParser._fix_toml_multiline_string(toml_str)) 107 | 108 | @staticmethod 109 | def _fix_toml_multiline_string(toml_str: str) -> str: 110 | # TODO fix this as it makes everything on one line 111 | new = "" 112 | basic_start = re.compile(r"=\s*\"[^\"]") 113 | basic_end = re.compile(r"\"\s*$|\"[\s#]+") 114 | literal_start = re.compile(r"=\s*'[^']") 115 | literal_end = re.compile(r"'\s*$|'[\s#]+") 116 | basic_active = False 117 | literal_active = False 118 | for line in toml_str.split("\n"): 119 | # Basic 120 | if not literal_active and basic_start.search(line): 121 | if not basic_end.search(line): 122 | basic_active = True 123 | elif basic_active and basic_end.search(line): 124 | basic_active = False 125 | 126 | # Literal 127 | if not basic_active and literal_start.search(line): 128 | if not literal_end.search(line): 129 | literal_active = True 130 | elif literal_active and literal_end.search(line): 131 | literal_active = False 132 | 133 | new += line 134 | if basic_active or literal_active: 135 | new += " " 136 | else: 137 | new += "\n" 138 | return new 139 | 140 | @staticmethod 141 | def _log_found_mod(mod: Mod) -> None: 142 | TealPrint.verbose(f"Found {mod.mod_loader.value} mod: {mod}", color=LogColors.found) 143 | -------------------------------------------------------------------------------- /minecraft_mod_manager/gateways/jar_parser_test.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | 3 | import pytest 4 | 5 | from ..core.entities.mod import Mod 6 | from ..core.entities.mod_loaders import ModLoaders 7 | from .jar_parser import JarParser 8 | 9 | testdata_dir = Path(__file__).parent.joinpath("testdata") 10 | mod_dir = testdata_dir.joinpath("mods") 11 | 12 | forge_valid = Mod( 13 | "jei", 14 | "Just Enough Items", 15 | version="7.6.4.86", 16 | mod_loader=ModLoaders.forge, 17 | file="forge-valid.jar", 18 | ) 19 | fabric_valid = Mod( 20 | "carpet", 21 | "Carpet Mod in Fabric", 22 | version="1.4.16", 23 | mod_loader=ModLoaders.fabric, 24 | file="fabric-valid.jar", 25 | ) 26 | fabric_invalid_character = Mod( 27 | "capes", 28 | "Capes", 29 | version="1.1.2", 30 | mod_loader=ModLoaders.fabric, 31 | file="fabric-invalid-character.jar", 32 | ) 33 | fabric_invalid_control_character = Mod( 34 | "itemmodelfix", 35 | "Item Model Fix", 36 | version="1.0.2+1.17", 37 | mod_loader=ModLoaders.fabric, 38 | file="fabric-invalid-control-character.jar", 39 | ) 40 | toml_inline_comment = Mod( 41 | "twilightforest", 42 | "The Twilight Forest", 43 | version="${file.jarVersion}", 44 | mod_loader=ModLoaders.forge, 45 | file="toml-inline-comment.jar", 46 | ) 47 | 48 | 49 | def path(filename: str) -> Path: 50 | return mod_dir.joinpath(filename) 51 | 52 | 53 | @pytest.mark.parametrize( 54 | "name,file,expected", 55 | [ 56 | ( 57 | "Returns mod info when fabric", 58 | fabric_valid.file, 59 | fabric_valid, 60 | ), 61 | ( 62 | "Returns mod info when forge", 63 | forge_valid.file, 64 | forge_valid, 65 | ), 66 | ( 67 | "Returns no mod info when invalid mod", 68 | "invalid.jar", 69 | None, 70 | ), 71 | ( 72 | "Handles invalid characters in fabric json file", 73 | fabric_invalid_character.file, 74 | fabric_invalid_character, 75 | ), 76 | ( 77 | "Handles invalid control character in fabric json file", 78 | fabric_invalid_control_character.file, 79 | fabric_invalid_control_character, 80 | ), 81 | ( 82 | "Handle missing key 'mods' in forge file", 83 | toml_inline_comment.file, 84 | toml_inline_comment, 85 | ), 86 | ], 87 | ) 88 | def test_get_mod_info(name, file, expected): 89 | print(name) 90 | input = path(file) 91 | 92 | result = JarParser._get_mod_info(input) 93 | 94 | assert expected == result 95 | 96 | 97 | def test_get_mods(): 98 | expected = [ 99 | forge_valid, 100 | fabric_valid, 101 | fabric_invalid_character, 102 | fabric_invalid_control_character, 103 | toml_inline_comment, 104 | ] 105 | jar_parser = JarParser(mod_dir) 106 | result = jar_parser.mods 107 | 108 | assert sorted(expected) == sorted(result) 109 | 110 | 111 | def test_unbalanced_quotes(): 112 | with open(testdata_dir.joinpath("unbalanced-quotes.toml")) as file: 113 | obj = JarParser._load_toml(file.read()) 114 | 115 | assert obj["basic_wrong"] == "This description spans over multiple lines. But doesn't use correct syntax" 116 | assert obj["literal_wrong"] == "Multiline comment with more lines " 117 | assert obj["basic_correct"] == "this is a correct\ncomment\n" 118 | assert obj["literal_correct"] == "this is a correct\ncomment\n" 119 | -------------------------------------------------------------------------------- /minecraft_mod_manager/gateways/sqlite.py: -------------------------------------------------------------------------------- 1 | import sqlite3 2 | from os import path 3 | from typing import Any, Dict, List, Union 4 | 5 | from tealprint import TealPrint 6 | 7 | from ..config import config 8 | from ..core.entities.mod import Mod 9 | from ..core.entities.sites import Site, Sites 10 | from ..core.errors.mod_already_exists import ModAlreadyExists 11 | from ..gateways.sqlite_upgrader import SqliteUpgrader 12 | from ..utils.log_colors import LogColors 13 | 14 | 15 | class _Column: 16 | c_id = "id" 17 | c_sites = "sites" 18 | c_upload_time = "upload_time" 19 | c_active = "active" 20 | 21 | def __init__( 22 | self, 23 | id: Any, 24 | sites: Any, 25 | upload_time: Any, 26 | active: Any, 27 | ) -> None: 28 | self.id = str(id) 29 | self.sites = _Column.string_sites_to_dict(sites) 30 | self.upload_time = int(upload_time) 31 | self.active = bool(active) 32 | 33 | @staticmethod 34 | def string_sites_to_dict(full_str: str) -> Dict[Sites, Site]: 35 | if "," in full_str: 36 | sites_strings = full_str.split(",") 37 | else: 38 | sites_strings = [full_str] 39 | 40 | sites: Dict[Sites, Site] = {} 41 | for site_str in sites_strings: 42 | if len(site_str) == 0: 43 | continue 44 | 45 | name_str, id, slug = site_str.split(":") 46 | if len(name_str) == 0: 47 | continue 48 | 49 | if len(id) == 0: 50 | id = None 51 | if len(slug) == 0: 52 | slug = None 53 | 54 | try: 55 | name = Sites[name_str] 56 | sites[name] = Site(name, id, slug) 57 | except KeyError: 58 | TealPrint.error(f"DB site {site_str} not a valid site, full: '{full_str}'") 59 | 60 | return sites 61 | 62 | @staticmethod 63 | def dict_sites_to_string(sites: Union[Dict[Sites, Site], None]) -> str: 64 | if not sites: 65 | return "" 66 | 67 | full_str = "" 68 | for site in sites.values(): 69 | if len(full_str) > 0: 70 | full_str += "," 71 | 72 | id = "" 73 | if site.id: 74 | id = site.id 75 | 76 | slug = "" 77 | if site.slug: 78 | slug = site.slug 79 | 80 | full_str += f"{site.name.value}:{id}:{slug}" 81 | 82 | return full_str 83 | 84 | 85 | class Sqlite: 86 | def __init__(self) -> None: 87 | file_path = path.join(config.dir, f".{config.app_name}.db") 88 | TealPrint.debug(f"DB location: {file_path}") 89 | self._connection = sqlite3.connect(file_path) 90 | self._cursor = self._connection.cursor() 91 | 92 | upgrader = SqliteUpgrader(self._connection, self._cursor) 93 | upgrader.upgrade() 94 | 95 | def close(self) -> None: 96 | self._cursor.close() 97 | self._connection.commit() 98 | self._connection.close() 99 | 100 | def sync_with_dir(self, mods: List[Mod]) -> List[Mod]: 101 | """Synchronize DB with the mods in the folder. This makes sure that we don't download 102 | mods that have been removed manually. 103 | 104 | Args: 105 | mods (List[Mod]): Mods present in the mods directory 106 | 107 | Returns: 108 | List[Mod]: Updated list with mod info 109 | """ 110 | mods = mods.copy() 111 | mods_to_add = mods.copy() 112 | 113 | # Go through all existing mods in the DB and see which should be added and removed 114 | db_mods = self._get_mods() 115 | for db_mod in db_mods.values(): 116 | # Search for existing info 117 | found = False 118 | for mod in mods_to_add: 119 | if mod.id == db_mod.id: 120 | found = True 121 | mods_to_add.remove(mod) 122 | TealPrint.debug(f"Found mod {mod.id} in DB", color=LogColors.found) 123 | 124 | # Reactivate mod 125 | if not db_mod.active: 126 | self._activate_mod(mod.id) 127 | break 128 | 129 | # Inactivate mod 130 | if not found: 131 | self._inactivate_mod(db_mod.id) 132 | 133 | # Add mods 134 | for mod in mods_to_add: 135 | TealPrint.debug(f"Adding mod {mod.id} to DB", color=LogColors.add) 136 | self.insert_mod(mod) 137 | 138 | # Update mod info 139 | for mod in mods: 140 | if mod.id in db_mods: 141 | db_mod = db_mods[mod.id] 142 | mod.upload_time = db_mod.upload_time 143 | 144 | # Update new mod sites info 145 | if len(mod.sites) > 0: 146 | self.update_mod(mod) 147 | else: 148 | mod.sites = db_mod.sites 149 | 150 | return mods 151 | 152 | def _get_mods(self) -> Dict[str, _Column]: 153 | self._cursor.execute( 154 | "SELECT " 155 | + f"{_Column.c_id}, " 156 | + f"{_Column.c_sites}, " 157 | + f"{_Column.c_upload_time}, " 158 | + f"{_Column.c_active} " 159 | + "FROM mod" 160 | ) 161 | mods: Dict[str, _Column] = {} 162 | rows = self._cursor.fetchall() 163 | for row in rows: 164 | id, sites, upload_time, active = row 165 | mods[id] = _Column( 166 | id=id, 167 | sites=sites, 168 | upload_time=upload_time, 169 | active=active, 170 | ) 171 | return mods 172 | 173 | def exists(self, id: str, filter_active: bool = True) -> bool: 174 | extra_filter = "" 175 | if filter_active: 176 | extra_filter = f"AND {_Column.c_active}=1" 177 | self._cursor.execute(f"SELECT 1 FROM mod WHERE {_Column.c_id}=? {extra_filter}", [id]) 178 | return bool(self._cursor.fetchone()) 179 | 180 | def update_mod(self, mod: Mod): 181 | if config.pretend: 182 | return 183 | 184 | if self.exists(mod.id, filter_active=False): 185 | self._cursor.execute( 186 | "UPDATE mod SET " 187 | + f"{_Column.c_sites}=?, " 188 | + f"{_Column.c_upload_time}=? " 189 | + "WHERE " 190 | + f"{_Column.c_id}=?", 191 | [_Column.dict_sites_to_string(mod.sites), mod.upload_time, mod.id], 192 | ) 193 | self._connection.commit() 194 | else: 195 | self.insert_mod(mod) 196 | 197 | def insert_mod(self, mod: Mod): 198 | if config.pretend: 199 | return 200 | 201 | try: 202 | self._cursor.execute( 203 | "INSERT INTO mod (" 204 | + f"{_Column.c_id}, " 205 | + f"{_Column.c_sites}, " 206 | + f"{_Column.c_upload_time}, " 207 | + f"{_Column.c_active}) " 208 | + "VALUES (?, ?, ?, 1)", 209 | [mod.id, _Column.dict_sites_to_string(mod.sites), mod.upload_time], 210 | ) 211 | self._connection.commit() 212 | except sqlite3.IntegrityError: 213 | raise ModAlreadyExists(mod) 214 | 215 | def _activate_mod(self, id: str): 216 | if config.pretend: 217 | return 218 | 219 | TealPrint.debug(f"Reactivate mod {id} in DB", color=LogColors.add) 220 | self._cursor.execute(f"UPDATE mod SET {_Column.c_active}=1 WHERE {_Column.c_id}=?", [id]) 221 | self._connection.commit() 222 | 223 | def _inactivate_mod(self, id: str): 224 | if config.pretend: 225 | return 226 | 227 | TealPrint.debug(f"Inactivate mod {id} in DB", color=LogColors.remove) 228 | self._cursor.execute(f"UPDATE mod SET {_Column.c_active}=0 WHERE {_Column.c_id}=?", [id]) 229 | self._connection.commit() 230 | -------------------------------------------------------------------------------- /minecraft_mod_manager/gateways/sqlite_upgrader.py: -------------------------------------------------------------------------------- 1 | import sqlite3 2 | 3 | from ..core.entities.sites import Sites 4 | 5 | 6 | class SqliteUpgrader: 7 | _version = 2 8 | 9 | def __init__(self, connection: sqlite3.Connection, cursor: sqlite3.Cursor) -> None: 10 | self._connection = connection 11 | self._cursor = cursor 12 | 13 | def upgrade(self): 14 | # First run time 15 | if not self._mod_table_exists(): 16 | self._create_version_table() 17 | self._create_mod_table() 18 | self._connection.commit() 19 | return 20 | 21 | version = self._get_version() 22 | 23 | # Upgrade tables one version at a time 24 | if version <= 0: 25 | self._v0_to_v1() 26 | if version <= 1: 27 | self._v1_to_v2() 28 | 29 | # Update version 30 | self._cursor.execute("UPDATE version SET version=?", [SqliteUpgrader._version]) 31 | self._connection.commit() 32 | 33 | def _get_version(self) -> int: 34 | if self._version_table_exists(): 35 | self._cursor.execute("SELECT version FROM version") 36 | return int(self._cursor.fetchone()[0]) 37 | return 0 38 | 39 | def _version_table_exists(self): 40 | self._cursor.execute("SELECT count(name) FROM sqlite_master WHERE type='table' AND name='version'") 41 | return self._cursor.fetchone()[0] == 1 42 | 43 | def _mod_table_exists(self): 44 | self._cursor.execute("SELECT count(name) FROM sqlite_master WHERE type='table' AND name='mod'") 45 | return self._cursor.fetchone()[0] == 1 46 | 47 | def _create_mod_table(self): 48 | self._create_mod_table_v2() 49 | 50 | def _create_mod_table_v1(self): 51 | self._connection.execute( 52 | "CREATE TABLE IF NOT EXISTS mod (" 53 | + "id TEXT UNIQUE, " 54 | + "site TEXT, " 55 | + "site_id TEXT, " 56 | + "site_slug TEXT, " 57 | + "upload_time INTEGER, " 58 | + "active INTEGER)" 59 | ) 60 | 61 | def _create_mod_table_v2(self): 62 | self._connection.execute( 63 | "CREATE TABLE IF NOT EXISTS mod (" 64 | + "id TEXT UNIQUE, " 65 | + "sites TEXT, " 66 | + "upload_time INTEGER, " 67 | + "active INTEGER)" 68 | ) 69 | 70 | def _create_version_table(self): 71 | self._cursor.execute("CREATE TABLE IF NOT EXISTS version (version INTEGER)") 72 | self._cursor.execute("INSERT INTO version (version) VALUES (?)", [SqliteUpgrader._version]) 73 | 74 | def _v0_to_v1(self): 75 | # Just remove mod table and create it again... 76 | self._cursor.execute("DROP TABLE mod") 77 | self._create_mod_table_v1() 78 | self._create_version_table() 79 | self._connection.commit() 80 | 81 | def _v1_to_v2(self): 82 | self._cursor.execute("SELECT * FROM mod") 83 | rows = self._cursor.fetchall() 84 | self._cursor.execute("DROP TABLE mod") 85 | self._create_mod_table_v2() 86 | 87 | for row in rows: 88 | id, site, site_id, site_slug, upload_time, active = row 89 | 90 | combined_site = "" 91 | if site: 92 | try: 93 | if site_id == "None": 94 | site_id = "" 95 | if site_slug == "None": 96 | site_slug = "" 97 | 98 | valid_site = Sites[site] 99 | combined_site = f"{valid_site.value}:{site_id}:{site_slug}" 100 | except KeyError: 101 | # Skip if the site is invalid 'unknown' 102 | pass 103 | 104 | new = (id, combined_site, upload_time, active) 105 | self._cursor.execute("INSERT INTO mod (id, sites, upload_time, active) VALUES (?, ?, ?, ?)", new) 106 | 107 | self._connection.commit() 108 | -------------------------------------------------------------------------------- /minecraft_mod_manager/gateways/sqlite_upgrader_test.py: -------------------------------------------------------------------------------- 1 | import os 2 | import sqlite3 3 | 4 | import pytest 5 | from minecraft_mod_manager.gateways.sqlite_upgrader import SqliteUpgrader 6 | 7 | from ..config import config 8 | from .sqlite import Sqlite 9 | 10 | db_file = f".{config.app_name}.db" 11 | 12 | 13 | @pytest.fixture 14 | def db() -> sqlite3.Connection: 15 | db = sqlite3.connect(db_file) 16 | yield db 17 | db.close() 18 | os.remove(db_file) 19 | 20 | 21 | @pytest.fixture 22 | def cursor(db: sqlite3.Connection) -> sqlite3.Cursor: 23 | cursor = db.cursor() 24 | yield cursor 25 | cursor.close() 26 | 27 | 28 | def test_create_tables_on_first_run(cursor: sqlite3.Cursor): 29 | sqlite = Sqlite() 30 | sqlite.close() 31 | 32 | cursor.execute("SELECT * FROM mod") 33 | mods = cursor.fetchall() 34 | assert mods == [] 35 | 36 | assert_version(cursor) 37 | 38 | 39 | def test_v0_to_v1(db: sqlite3.Connection, cursor: sqlite3.Cursor): 40 | cursor.execute( 41 | "CREATE TABLE mod (" 42 | + "id TEXT UNIQUE, " 43 | + "repo_type TEXT, " 44 | + "repo_name TEXT, " 45 | + "upload_time INTEGER, " 46 | + "active INTEGER)" 47 | ) 48 | cursor.execute( 49 | "INSERT INTO mod (id, repo_type, repo_name, upload_time, active) VALUES " 50 | + "('carpet', 'curse', 'carpet', 1, 1)" 51 | ) 52 | db.commit() 53 | 54 | upgrader = SqliteUpgrader(db, cursor) 55 | upgrader._v0_to_v1() 56 | 57 | cursor.execute("SELECT * FROM mod") 58 | mods = cursor.fetchall() 59 | assert [] == mods 60 | 61 | assert_version(cursor) 62 | 63 | 64 | @pytest.mark.parametrize( 65 | "test_name,input,expected", 66 | [ 67 | ( 68 | "Migrates all fields when present", 69 | ("id", "curse", "site_id", "site_slug", 123, 1), 70 | ("id", "curse:site_id:site_slug", 123, 1), 71 | ), 72 | ( 73 | "Skip saving slug when no site is specified", 74 | ("id", "", "", "slug", 123, 1), 75 | ("id", "", 123, 1), 76 | ), 77 | ( 78 | "Migrate slug when no site_id", 79 | ("id", "curse", "", "site_slug", 123, 0), 80 | ("id", "curse::site_slug", 123, 0), 81 | ), 82 | ( 83 | "Skip when site is unknown", 84 | ("id", "unknown", "", "", 123, 1), 85 | ("id", "", 123, 1), 86 | ), 87 | ( 88 | "Convert 'None' to empty", 89 | ("id", "curse", "None", "None", 123, 1), 90 | ("id", "curse::", 123, 1), 91 | ), 92 | ], 93 | ) 94 | def test_v1_to_v2(test_name: str, input, expected, db: sqlite3.Connection, cursor: sqlite3.Cursor): 95 | print(test_name) 96 | 97 | cursor.execute( 98 | "CREATE TABLE mod (" 99 | + "id TEXT UNIQUE, " 100 | + "site TEXT, " 101 | + "site_id TEXT, " 102 | + "site_slug TEXT, " 103 | + "upload_time INTEGER, " 104 | + "active INTEGER)" 105 | ) 106 | cursor.execute( 107 | "INSERT INTO mod (id, site, site_id, site_slug, upload_time, active) VALUES (?, ?, ?, ?, ?, ?)", input 108 | ) 109 | db.commit() 110 | 111 | upgrader = SqliteUpgrader(db, cursor) 112 | upgrader._v1_to_v2() 113 | 114 | cursor.execute("SELECT * FROM mod") 115 | result = cursor.fetchall() 116 | 117 | assert [expected] == result 118 | 119 | 120 | def create_version_table(version: int, db: sqlite3.Connection, cursor: sqlite3.Cursor) -> None: 121 | cursor.execute("CREATE TABLE version (version INTEGER)") 122 | cursor.execute("INSERT INTO version (version) VALUES(?)", (version,)) 123 | 124 | 125 | def assert_version(cursor: sqlite3.Cursor) -> None: 126 | cursor.execute("SELECT * FROM version") 127 | versions = cursor.fetchall() 128 | assert [(SqliteUpgrader._version,)] == versions 129 | -------------------------------------------------------------------------------- /minecraft_mod_manager/gateways/testdata/mods/README.md: -------------------------------------------------------------------------------- 1 | # testdata 2 | 3 | Test data for checking that we read .jars correctly. 4 | These are not full mods, they only contain the mod metadata. 5 | -------------------------------------------------------------------------------- /minecraft_mod_manager/gateways/testdata/mods/fabric-invalid-character.jar: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Senth/minecraft-mod-manager/eb10f6769c38602772daedcb42c2beca7e67d6b7/minecraft_mod_manager/gateways/testdata/mods/fabric-invalid-character.jar -------------------------------------------------------------------------------- /minecraft_mod_manager/gateways/testdata/mods/fabric-invalid-control-character.jar: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Senth/minecraft-mod-manager/eb10f6769c38602772daedcb42c2beca7e67d6b7/minecraft_mod_manager/gateways/testdata/mods/fabric-invalid-control-character.jar -------------------------------------------------------------------------------- /minecraft_mod_manager/gateways/testdata/mods/fabric-valid.jar: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Senth/minecraft-mod-manager/eb10f6769c38602772daedcb42c2beca7e67d6b7/minecraft_mod_manager/gateways/testdata/mods/fabric-valid.jar -------------------------------------------------------------------------------- /minecraft_mod_manager/gateways/testdata/mods/forge-valid.jar: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Senth/minecraft-mod-manager/eb10f6769c38602772daedcb42c2beca7e67d6b7/minecraft_mod_manager/gateways/testdata/mods/forge-valid.jar -------------------------------------------------------------------------------- /minecraft_mod_manager/gateways/testdata/mods/invalid.jar: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Senth/minecraft-mod-manager/eb10f6769c38602772daedcb42c2beca7e67d6b7/minecraft_mod_manager/gateways/testdata/mods/invalid.jar -------------------------------------------------------------------------------- /minecraft_mod_manager/gateways/testdata/mods/toml-inline-comment.jar: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Senth/minecraft-mod-manager/eb10f6769c38602772daedcb42c2beca7e67d6b7/minecraft_mod_manager/gateways/testdata/mods/toml-inline-comment.jar -------------------------------------------------------------------------------- /minecraft_mod_manager/gateways/testdata/unbalanced-quotes.toml: -------------------------------------------------------------------------------- 1 | basic_wrong = "This description spans over 2 | multiple lines. But doesn't use correct syntax" 3 | literal_wrong = 'Multiline 4 | comment 5 | with 6 | more 7 | lines 8 | ' 9 | basic_correct = """ 10 | this is a correct 11 | comment 12 | """ 13 | literal_correct = ''' 14 | this is a correct 15 | comment 16 | ''' 17 | -------------------------------------------------------------------------------- /minecraft_mod_manager/utils/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Senth/minecraft-mod-manager/eb10f6769c38602772daedcb42c2beca7e67d6b7/minecraft_mod_manager/utils/__init__.py -------------------------------------------------------------------------------- /minecraft_mod_manager/utils/log_colors.py: -------------------------------------------------------------------------------- 1 | from colored import attr, fg 2 | 3 | 4 | class LogColors: 5 | add = fg("green") 6 | updated = fg("green") 7 | remove = fg("red") 8 | error = fg("red") 9 | found = fg("cyan") 10 | command = fg("blue") 11 | skip = fg("yellow") 12 | not_found = fg("yellow") 13 | header = attr("bold") 14 | no_color = attr("reset") 15 | -------------------------------------------------------------------------------- /pytest.ini: -------------------------------------------------------------------------------- 1 | [pytest] 2 | addopts = -ra -q 3 | -------------------------------------------------------------------------------- /renovate.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": [ 3 | "config:base" 4 | ], 5 | "dependencyDashboard": false 6 | } 7 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Senth/minecraft-mod-manager/eb10f6769c38602772daedcb42c2beca7e67d6b7/requirements.txt -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [flake8] 2 | max-line-length = 119 3 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | from setuptools import find_packages, setup 2 | 3 | with open("README.md", "r") as fh: 4 | long_description = fh.read() 5 | 6 | module = "minecraft_mod_manager" 7 | package = module.replace("_", "-") 8 | 9 | setup( 10 | name=package, 11 | use_scm_version=True, 12 | url="https://github.com/Senth/minecraft-mod-manager", 13 | license="MIT", 14 | author="Matteus Magnusson", 15 | author_email="senth.wallace@gmail.com", 16 | description="Download and update Minecraft mods from CurseForge and possibly other places in the future.", 17 | long_description=long_description, 18 | long_description_content_type="text/markdown", 19 | packages=find_packages(), 20 | entry_points={ 21 | "console_scripts": [ 22 | f"{package}={module}.__main__:main", 23 | f"mcman={module}.__main__:main", 24 | f"mmm={module}.__main__:main", 25 | ], 26 | }, 27 | install_requires=[ 28 | "latest-user-agents", 29 | "requests", 30 | "tealprint>=0.3.0", 31 | "toml", 32 | ], 33 | classifiers=[ 34 | "Development Status :: 5 - Production/Stable", 35 | "License :: OSI Approved :: MIT License", 36 | "Programming Language :: Python :: 3.8", 37 | "Programming Language :: Python :: 3.9", 38 | "Programming Language :: Python :: 3.10", 39 | ], 40 | setup_requires=[ 41 | "setuptools_scm", 42 | "pytest-runner", 43 | ], 44 | tests_require=[ 45 | "pytest", 46 | "mockito", 47 | ], 48 | python_requires=">=3.8", 49 | ) 50 | -------------------------------------------------------------------------------- /tests/README.md: -------------------------------------------------------------------------------- 1 | # Integration Tests 2 | 3 | This folder contains all the integration tests for minecraft mod manager. 4 | It makes sure that the `minecraft-mod-manager` command works correctly 5 | by testing various scenarios. 6 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Senth/minecraft-mod-manager/eb10f6769c38602772daedcb42c2beca7e67d6b7/tests/__init__.py -------------------------------------------------------------------------------- /tests/install_test.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from .util.helper import Helper 4 | 5 | 6 | @pytest.fixture 7 | def helper(): 8 | helper = Helper() 9 | yield helper 10 | helper.unstub() 11 | 12 | 13 | # TODO CurseForge disabled for now 14 | # def test_reinstall_mod_after_manual_removal(helper: Helper): 15 | # code = helper.run("install", "carpet") 16 | # carpet_mod = helper.get_mod_in_dir_like("*carpet*.jar") 17 | # assert carpet_mod is not None 18 | # assert code == 0 19 | 20 | # carpet_mod.unlink() 21 | 22 | # code = helper.run("install", "carpet") 23 | # carpet_mod = helper.get_mod_in_dir_like("*carpet*.jar") 24 | # assert carpet_mod is not None 25 | # assert code == 0 26 | 27 | 28 | # def test_install_mod_that_has_no_mod_loader(helper: Helper): 29 | # code = helper.run("install", "jei") 30 | # jei = helper.get_mod_in_dir_like("*jei*.jar") 31 | 32 | # assert code == 0 33 | # assert jei is not None 34 | 35 | 36 | # def test_remember_slug_for_installed_mod_if_mod_id_varies(helper: Helper): 37 | # """When installing and supplying a slug it used to save the slug as an id. 38 | # This test makes sure that we actually check what the mod id is before saving 39 | # to the database. 40 | # """ 41 | # code = helper.run("install", "unearthed-fabric") 42 | 43 | # assert code == 0 44 | # assert helper.exists_in_db("unearthed") 45 | 46 | 47 | def test_install_from_modrinth(helper: Helper): 48 | code = helper.run("install", "lithium=modrinth:lithium") 49 | lithium_mod = helper.get_mod_in_dir_like("*lithium*.jar") 50 | 51 | assert lithium_mod is not None 52 | assert code == 0 53 | 54 | 55 | # TODO CurseForge disabled for now 56 | # def test_install_entity_culling_when_using_word_splitter(helper: Helper): 57 | # """Tests to make sure that the word splitter is working correctly. 58 | # Some mods have names that makes them hard to search for on both Curse and 59 | # Modrinth. This test makes sure that we can search for those mods. 60 | # """ 61 | # code = helper.run("install", "entityculling") 62 | # entity_culling = helper.get_mod_in_dir_like("*entityculling*.jar") 63 | 64 | # assert code == 0 65 | # assert entity_culling is not None 66 | 67 | 68 | # def test_install_dependencies(helper: Helper): 69 | # """Tests that dependencies are installed correctly.""" 70 | # code = helper.run("install", "lambdabettergrass") 71 | # lamba_better_crass = helper.get_mod_in_dir_like("lambdabettergrass*.jar") 72 | # dependency_fabric_api = helper.get_mod_in_dir_like("fabric-api*.jar") 73 | 74 | # assert code == 0 75 | # assert lamba_better_crass is not None 76 | # assert dependency_fabric_api is not None 77 | 78 | 79 | def test_install_dcch(helper: Helper): 80 | code = helper.run("install", "dcch") 81 | dcch = helper.get_mod_in_dir_like("DCCH*.jar") 82 | 83 | assert code == 0 84 | assert dcch is not None 85 | 86 | 87 | def test_install_fabric_version(helper: Helper): 88 | code = helper.run("-v", "1.16.5", "--mod-loader", "fabric", "--alpha", "install", "simple-voice-chat") 89 | smc = helper.get_mod_in_dir_like("voicechat*.jar") 90 | 91 | assert code == 0 92 | assert smc is None 93 | -------------------------------------------------------------------------------- /tests/update_test.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from .util.helper import Helper 4 | 5 | 6 | @pytest.fixture 7 | def helper(): 8 | helper = Helper() 9 | yield helper 10 | helper.unstub() 11 | 12 | 13 | # TODO CurseForge disabled for now 14 | # def test_update_does_not_remove_mods(helper: Helper): 15 | # code = helper.run("install", "carpet", "-v", "1.16.2") 16 | # carpet_mod = helper.get_mod_in_dir_like("*carpet*.jar") 17 | # assert code == 0 18 | # assert carpet_mod is not None 19 | 20 | # code = helper.run("update") 21 | # carpet_mod = helper.get_mod_in_dir_like("*carpet*.jar") 22 | # assert code == 0 23 | # assert carpet_mod is not None 24 | 25 | # code = helper.run("update") 26 | # carpet_mod = helper.get_mod_in_dir_like("*carpet*.jar") 27 | # assert code == 0 28 | # assert carpet_mod is not None 29 | -------------------------------------------------------------------------------- /tests/util/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Senth/minecraft-mod-manager/eb10f6769c38602772daedcb42c2beca7e67d6b7/tests/util/__init__.py -------------------------------------------------------------------------------- /tests/util/helper.py: -------------------------------------------------------------------------------- 1 | import sqlite3 2 | from pathlib import Path 3 | from subprocess import run 4 | from typing import List, Optional 5 | 6 | 7 | class Helper: 8 | def __init__(self) -> None: 9 | self.dir = Path("temp") 10 | self.dir.mkdir(exist_ok=True) 11 | 12 | self.cmd = ["python", "-m", "minecraft_mod_manager", "--dir", "temp"] 13 | 14 | self.db = sqlite3.connect(self.dir.joinpath(".minecraft-mod-manager.db")) 15 | self.cursor = self.db.cursor() 16 | 17 | def run(self, *args: str) -> int: 18 | cmd: List[str] = [*self.cmd, *args] 19 | output = run(cmd) 20 | return output.returncode 21 | 22 | def get_mod_in_dir_like(self, glob: str) -> Optional[Path]: 23 | for file in self.dir.glob(glob): 24 | return file 25 | return None 26 | 27 | def exists_in_db(self, id: str) -> bool: 28 | self.cursor.execute("SELECT * FROM mod WHERE id=?", [id]) 29 | return self.cursor.fetchone() is not None 30 | 31 | def unstub(self) -> None: 32 | self.cursor.close() 33 | self.db.close() 34 | 35 | for file in self.dir.iterdir(): 36 | file.unlink() 37 | 38 | self.dir.rmdir() 39 | --------------------------------------------------------------------------------