Skip to content
This repository has been archived by the owner on Oct 27, 2024. It is now read-only.

Commit

Permalink
Merge pull request #9 from tygoee/dev
Browse files Browse the repository at this point in the history
Add command-line support, cleanup after tests and some convenience changes
  • Loading branch information
tygoee authored Nov 17, 2023
2 parents 2a85391 + c485552 commit 64decff
Show file tree
Hide file tree
Showing 8 changed files with 202 additions and 52 deletions.
22 changes: 20 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,11 +6,29 @@

Minecraft modpack manager - This solves the problem of having to install mods manually. Currently, you need to edit the file manually, as this is still in it's alpha phase. You can currently download mods from curseforge, modrinth and planet minecraft and custom urls. You can also install forge automatically. Currently, mods, resourcepacks and shaderpacks are supported.

## "Building" to a `.pyz` file

To make a `.pyz` file, you need to zip the entirety of `src/`, rename it extension to `mcm-manager.pyz` and run:

Unix/Linux:

```shell
python3 mcm-manager.pyz
```

Windows:

```shell
python mcm-manager.pyz
```

To make it an app always accessible from the command line in Unix/Linux, add the [shebang](<https://en.wikipedia.org/wiki/Shebang_(Unix)>) `#!/usr/bin/python3` at the start of the `.pyz` file and copy it to `~/.local/bin/`.

## Creating a mcm file

The mod types are `cf`, `mr`, `pm` and `url`

You can get a mod slug/id from the mod url:
You can get a mod slug/id from the download page url (which you copy from the url bar or by going to downloads → the file → right click → Go to download page):

- https://www.curseforge.com/minecraft/mc-mods/worldedit/download/4586218
`slug` = worldedit
Expand All @@ -21,7 +39,7 @@ You can get a mod slug/id from the mod url:
- https://www.planetminecraft.com/texture-pack/1-13-1-16-unique-spawn-eggs/
`slug` = 1-13-1-16-unique-spawn-eggs

To get the rest of the information, go to downloads → the file, right click and copy the download url:
To get the rest of the information, go to downloads → the fileright click the download url:

- https://mediafilez.forgecdn.net/files/4586/218/worldedit-mod-7.2.15.jar
`name` = worldedit-mod-7.2.15.jar
Expand Down
199 changes: 161 additions & 38 deletions src/__main__.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,20 +14,59 @@
# You should have received a copy of the GNU General Public License
# along with this program. If not, see <https://www.gnu.org/licenses/>.

from argparse import ArgumentParser, Namespace
from dataclasses import dataclass, fields
from os import path, makedirs
from install import filesize, media, modloaders
from typing import Any, Literal, Optional, TypedDict, TypeVar

from install import filesize, media, modloaders
from install._types import URLMedia, MediaList, Side


class Options(TypedDict):
manifest_file: str
install_path: str
side: Side
install_modloader: bool
launcher_path: str
confirm: bool


@dataclass(init=False)
class Args:
# Positional arguments
pos: Literal[False] | str

# Options
y: bool # confirm installation
o: bool # skip modloader install
m: Optional[str] # manifest
i: Optional[str] # install path
s: Optional[Side] # side
l: Optional[str] # launcher path

def __init__(self, **kwargs: Any) -> None:
"""Ignore non-existent names"""
names = set(f.name for f in fields(self))
for k, v in kwargs.items():
if k in names:
setattr(self, k, v)


current_dir = path.dirname(path.realpath(__file__))
app_root = path.join(path.dirname(path.realpath(__file__)), '..')
default_args = Args(**vars(Namespace(
pos=False, y=False, m=None, i=None, s=None, l=None, o=False)))


def install(manifest_file: str,
install_path: str = path.join(app_root, 'share', '.minecraft'),
side: Side = 'client',
install_modloader: bool = True,
launcher_path: str = modloaders.MINECRAFT_DIR,
confirm: bool = True) -> None:
def install(
manifest_file: str,
install_path: str = path.join(app_root, 'share', '.minecraft'),
side: Side = 'client',
install_modloader: bool = True,
launcher_path: str = modloaders.MINECRAFT_DIR,
confirm: bool = True
) -> None:
"""
Install a list of mods, resourcepacks, shaderpacks and config files. Arguments:
Expand Down Expand Up @@ -66,23 +105,26 @@ def install(manifest_file: str,
[shaderpack for shaderpack in shaderpacks]
if _media['type'] == 'url']
if len(external_media) != 0:
print("\nWARNING! Some mods/resourcepacks/shaderpacks are from" +
print("\nWARNING! Some mods/resourcepacks/shaderpacks are from"
" external sources and could harm your system:")
for _media in external_media:
print(
f" {_media['slug']} ({_media['name']}): {_media['url']}")
print(f" {_media['slug']} ({_media['name']}): {_media['url']}")

# Print the mod info
print(
f"\n{len(mods)} mods, {len(resourcepacks)} " +
f"recourcepacks, {len(shaderpacks)} shaderpacks\n" +
f"Total file size: {filesize.size(total_size, system=filesize.alternative)}")
f"\n{len(mods)} mods, {len(resourcepacks)} recourcepacks, {len(shaderpacks)} shaderpacks\n"
f"Total file size: {filesize.size(total_size, system=filesize.alternative)}"
)

# Ask for confirmation if confirm is True and install all modpacks
if confirm:
if input("Continue? (Y/n) ").lower() not in ['y', '']:
print("Cancelling...\n")
exit()
try:
if input("Continue? (Y/n) ").lower() not in ['y', '']:
print("Cancelling...\n")
exit()
except KeyboardInterrupt:
print(end='\n')
exit(130)
else:
print("Continue (Y/n) ")

Expand All @@ -93,65 +135,146 @@ def install(manifest_file: str,
if side == 'client':
modloaders.forge(modpack_version, modloader_version,
side, install_path, launcher_path)
if side == 'server':
elif side == 'server':
modloaders.forge(modpack_version, modloader_version,
side, install_path)
case _:
print("Installing this modloader isn't supported yet.")
print("WARNING: Couldn't install modloader because it isn't supported.")

# Download all files
media.download_files(total_size, install_path, side, manifest)


def main():
from typing import TypedDict, Literal
_T = TypeVar("_T")


def ask(arg: _T | None, question: _T) -> _T | str:
return input(question) if arg is None else arg


def ask_yes(arg: _T, question: _T) -> bool:
return input(question).lower() == 'y' if arg else arg

Answers = TypedDict("Answers", {
"manifest_file": str,
"install_path": str,
"side": Literal['client', 'server'],
"install_modloader": bool,
"launcher_path": str
})

current_dir = path.dirname(path.realpath(__file__))
def cli(args: Args = default_args):
"""Ask questions and execute `install()` with the answers"""

# Define all questions
questions = [
"Manifest file location (default: example-manifest.json): ",
"Install location (default: share/gamedir): ",
"Install side (client/server, default: client): ",
"Do you want to install the modloader? (Y/n, default: n): ",
"Do you want to install the modloader? (y/N, default: n): ",
f"Launcher location (default: {modloaders.MINECRAFT_DIR}): "
]

# Ask all questions
answers: Answers = {
"manifest_file": input(questions[0]),
"install_path": input(questions[1]),
"side": 'server' if input(questions[2]) == 'server' else 'client',
"install_modloader": (inst_modl := True if input(questions[3]).lower() == 'y' else False),
"launcher_path": input(questions[4]) if inst_modl else ''
}
try:
answers: Options = {
"manifest_file": ask(args.m, questions[0]),
"install_path": ask(args.i, questions[1]),
"side": 'server' if ask(args.s, questions[2]) == 'server' else 'client',
"install_modloader": (inst_modl := ask_yes(not args.o, questions[3])),
"launcher_path": ask(args.l, questions[4]) if inst_modl else '',
"confirm": not args.y
}
except KeyboardInterrupt:
print(end='\n')
exit(130)

# Set the defaults
defaults = {
"launcher_path": modloaders.MINECRAFT_DIR,
"manifest_file": path.join(current_dir, '..', 'share', 'modpacks', 'example-manifest.json'),
"install_path": path.join(current_dir, '..', 'share', 'gamedir')
"install_path": path.join(current_dir, '..', 'share', 'gamedir'),
"launcher_path": modloaders.MINECRAFT_DIR
}

# Cycle through them and apply
for default in defaults:
if answers[default] == '':
answers[default] = defaults[default]

# Make the install directory if it's nonexistent
if not path.isdir(answers["install_path"]):
makedirs(answers["install_path"])

print('\n', end='')

# Install
install(**answers)


def main():
# Make a parser
parser = ArgumentParser(
prog="mcm-manager",
description="Minecraft Modpack Manager"
)

# Add positional arguments
parser.add_argument(
'pos', nargs='?', default=False, metavar='cli',
help='use a cli interface to install a modloader'
)

parser.add_argument(
'install', nargs='?', default=False,
help='install a package directly'
)

# Define optional arguments
optional_args: list[tuple[tuple[str, ...], str, str] | tuple[tuple[str, ...], str]] = [
(('-y', '--yes'), "continue installation without confirmation"),
(('-m',), 'MANIFEST', "specify the manifest file to load"),
(('-i',), 'INSTPATH', "specify the path where it will be installed"),
(('-s',), 'SIDE', "specify the side to be installed (client or server)"),
(('-l',), 'LAUNCHERPATH', "specify the path of the launcher"),
(('-o',), "skip the installation of the modloader"),
]

# Add optional arguments
for arg in optional_args:
if len(arg) == 3:
parser.add_argument(
*arg[0], metavar=arg[1],
help=arg[2]
)
else:
parser.add_argument(
*arg[0], action='store_true',
dest=arg[0][0][1], help=arg[1]
)

# Get the args and execute the right function
args = Args(**vars(parser.parse_args()))

if args.s not in ('client', 'server') and args.s is not None:
raise TypeError("side has to be either 'client', 'server' or None.")

match args.pos:
case 'cli':
cli(args)
case 'install':
# Set the defaults
options: Options = {
"manifest_file": args.m or path.join(current_dir, '..', 'share',
'modpacks', 'example-manifest.json'),
"install_path": args.i or path.join(current_dir, '..', 'share', 'gamedir'),
"side": 'server' if args.s == 'server' else 'client',
"install_modloader": (inst_modl := not args.o),
"launcher_path": args.l if args.l is not None and inst_modl else modloaders.MINECRAFT_DIR,
"confirm": not args.y
}

install(**options)

case _:
# TODO else, execute the gui (not finished)
raise NotImplementedError(
"Calling without any (or invalid) positional arguments will open a gui, "
"but this is not implemented yet.\n" + parser.format_usage()
)


if __name__ == '__main__':
main()
10 changes: 5 additions & 5 deletions src/install/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,14 +22,14 @@ def media_url(media: Media) -> str:

match media['type']:
case 'cf':
url = "https://mediafilez.forgecdn.net/files/" + \
f"{int(str(media['id'])[:4])}/" + \
url = "https://mediafilez.forgecdn.net/files/" \
f"{int(str(media['id'])[:4])}/" \
f"{int(str(media['id'])[4:])}/{media['name']}"
case 'mr':
url = "https://cdn-raw.modrinth.com/data/" + \
url = "https://cdn-raw.modrinth.com/data/" \
f"{media['id'][:8]}/versions/{media['id'][8:]}/{media['name']}"
case 'pm':
url = "https://static.planetminecraft.com/files/resource_media/" + \
url = "https://static.planetminecraft.com/files/resource_media/" \
f"{media['media']}/{media['name']}"
case 'url':
url: str = media['url']
Expand All @@ -42,7 +42,7 @@ class forge:

@staticmethod
def forge_installer_url(mc_version: str, forge_version: str) -> str:
return "https://maven.minecraftforge.net/net/minecraftforge/forge/" + \
return "https://maven.minecraftforge.net/net/minecraftforge/forge/" \
f"{mc_version}-{forge_version}/forge-{mc_version}-{forge_version}-installer.jar"


Expand Down
2 changes: 2 additions & 0 deletions tests/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -46,3 +46,5 @@

with open(path.join(launcher_dir, 'launcher_profiles.json'), 'w') as fp:
dump(launcher_profiles, fp, indent=4)

del path, mkdir, dump, install_dir, launcher_dir, launcher_profiles, fp
3 changes: 3 additions & 0 deletions tests/install/test_media.py
Original file line number Diff line number Diff line change
Expand Up @@ -50,3 +50,6 @@ def _test_download(self):
empty_dir(install_dir)
download_files(self.total_size_server,
install_dir, 'server', manifest)

def tearDown(self):
empty_dir(install_dir)
3 changes: 3 additions & 0 deletions tests/install/test_modloaders.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,9 @@ def test_forge_server(self):
empty_dir(install_dir)
forge('1.20.1', '47.1.0', 'server', install_dir)

def tearDown(self):
empty_dir(install_dir, launcher_dir)


if False: # Tests of unfinished features
@quiet
Expand Down
2 changes: 1 addition & 1 deletion tests/install/test_urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,7 @@ def test_urls(self):
"type": "url",
"slug": "essential",
"name": "Essential-fabric_1-20-1.jar",
"url": "https://cdn.essential.gg/mods/60ecf53d6b26c76a26d49e5b/" +
"url": "https://cdn.essential.gg/mods/60ecf53d6b26c76a26d49e5b/"
"649c45fb8b045520b2c1c8b2/Essential-fabric_1-20-1.jar",
"info": {
"title": "Essential",
Expand Down
13 changes: 7 additions & 6 deletions tests/vars.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,12 +29,13 @@ def wrapper(*args: Any, **kwargs: Any):
return wrapper


def empty_dir(directory: str):
for root, dirs, files in walk(directory):
for f in files:
remove(path.join(root, f))
for d in dirs:
rmtree(path.join(root, d))
def empty_dir(*directories: str):
for directory in directories:
for root, dirs, files in walk(directory):
for f in files:
remove(path.join(root, f))
for d in dirs:
rmtree(path.join(root, d))


launcher_dir = path.realpath(
Expand Down

0 comments on commit 64decff

Please sign in to comment.