├── .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 | [](https://pypi.python.org/pypi/minecraft-mod-manager)
4 | [](https://pypi.python.org/pypi/minecraft-mod-manager)
5 | [](https://lgtm.com/projects/g/Senth/minecraft-mod-manager/alerts/)
6 | [](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\n\nDrag your mouse around the crafting grid:\n\n\n\nYou can drag your mouse on top of existing items:\n\n\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\n\nDrag your mouse across the inventory. Items of the same type will be picked up:\n\n\n\nHold shift and drag. Items of the same type will get \"shift-clicked\":\n\n\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\n\nDrag your mouse across the inventory. Items will get \"shift-clicked\":\n\n*(Mouse cursor is not visible for some reason)*\n\n\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 |
--------------------------------------------------------------------------------