├── src ├── __init__.py ├── gradle │ ├── __init__.py │ ├── error.py │ ├── project.py │ └── command.py ├── shulkr │ ├── __init__.py │ ├── __main__.py │ ├── compatibility.py │ ├── gitignore.py │ ├── repo.py │ ├── cli.py │ ├── app.py │ ├── version.py │ └── config.py ├── minecraft │ ├── __init__.py │ ├── source.py │ └── version.py ├── mint │ ├── error.py │ ├── __init__.py │ ├── README.md │ └── repo.py ├── java │ ├── blob.py │ └── __init__.py └── command │ └── __init__.py ├── tests ├── __init__.py ├── java │ ├── __init__.py │ └── functional │ │ ├── __init__.py │ │ └── test__init__.py ├── mint │ ├── __init__.py │ ├── smoke │ │ ├── __init__.py │ │ ├── test_repo.py │ │ └── conftest.py │ └── unit │ │ ├── __init__.py │ │ └── test_repo.py ├── command │ ├── __init__.py │ ├── smoke │ │ ├── __init__.py │ │ ├── conftest.py │ │ └── test_command.py │ └── unit │ │ ├── __init__.py │ │ └── test_command.py ├── gradle │ ├── __init__.py │ ├── smoke │ │ ├── __init__.py │ │ ├── test_commands.py │ │ └── conftest.py │ └── unit │ │ ├── __init__.py │ │ ├── test_project.py │ │ └── test_command.py ├── minecraft │ ├── __init__.py │ └── unit │ │ ├── __init__.py │ │ ├── conftest.py │ │ ├── test_version.py │ │ └── test_source.py └── shulkr │ ├── __init__.py │ ├── profile │ ├── __init__.py │ ├── __main__.py │ └── profile_java.py │ ├── smoke │ ├── __init__.py │ ├── test_cli.py │ └── conftest.py │ ├── unit │ ├── __init__.py │ ├── test_repo.py │ ├── test_compatibility.py │ ├── test_gitignore.py │ ├── conftest.py │ ├── test_app.py │ ├── test_config.py │ └── test_version.py │ └── functional │ ├── __init__.py │ ├── .gitignore │ ├── minecraft │ ├── __init__.py │ └── test_version.py │ ├── test_file_system.py │ ├── test_git.py │ └── conftest.py ├── .python-version ├── scripts ├── __init__.py └── bump │ ├── __init__.py │ └── __main__.py ├── .gitignore ├── .vscode ├── extensions.json └── settings.json ├── .gitmodules ├── sample-git-hooks ├── pre-commit └── pre-push ├── .flake8 ├── .github └── workflows │ ├── check-on-push.yml │ ├── check.yml │ └── release-on-push-tag.yml ├── docs ├── usage-guidelines.md ├── changelog.md └── contributing.md ├── Pipfile ├── setup.py ├── README.md ├── LICENSE └── Pipfile.lock /src/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.python-version: -------------------------------------------------------------------------------- 1 | 3.9 2 | -------------------------------------------------------------------------------- /scripts/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/gradle/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/shulkr/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/java/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/mint/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /scripts/bump/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/minecraft/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/command/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/gradle/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/minecraft/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/mint/smoke/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/mint/unit/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/shulkr/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/command/smoke/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/command/unit/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/gradle/smoke/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/gradle/unit/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/java/functional/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/minecraft/unit/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/shulkr/profile/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/shulkr/smoke/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/shulkr/unit/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/shulkr/functional/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/shulkr/functional/.gitignore: -------------------------------------------------------------------------------- 1 | repo/ 2 | -------------------------------------------------------------------------------- /tests/shulkr/functional/minecraft/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | __pycache__/ 2 | 3 | # Build 4 | build/ 5 | dist/ 6 | shulkr.egg-info/ 7 | -------------------------------------------------------------------------------- /src/mint/error.py: -------------------------------------------------------------------------------- 1 | from command import CommandError 2 | 3 | 4 | class GitError(CommandError): 5 | pass 6 | -------------------------------------------------------------------------------- /src/gradle/error.py: -------------------------------------------------------------------------------- 1 | from command import CommandError 2 | 3 | 4 | class GradleError(CommandError): 5 | pass 6 | -------------------------------------------------------------------------------- /.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | "recommendations": [ 3 | "ms-python.flake8", 4 | "ms-python.black-formatter" 5 | ] 6 | } -------------------------------------------------------------------------------- /tests/shulkr/profile/__main__.py: -------------------------------------------------------------------------------- 1 | from . import profile_get_renamed_variables 2 | 3 | 4 | if __name__ == "__main__": 5 | profile_get_renamed_variables() 6 | -------------------------------------------------------------------------------- /src/shulkr/__main__.py: -------------------------------------------------------------------------------- 1 | from shulkr.cli import cli 2 | 3 | 4 | def main() -> None: 5 | cli() 6 | 7 | 8 | if __name__ == "__main__": 9 | main() 10 | -------------------------------------------------------------------------------- /.gitmodules: -------------------------------------------------------------------------------- 1 | [submodule "fabric-example-mod"] 2 | path = fabric-example-mod 3 | url = git@github.com:FabricMC/fabric-example-mod.git 4 | [submodule "shulkr/yarn"] 5 | path = shulkr/yarn 6 | url = https://github.com/FabricMC/yarn.git 7 | -------------------------------------------------------------------------------- /tests/gradle/smoke/test_commands.py: -------------------------------------------------------------------------------- 1 | from gradle.project import Project 2 | 3 | 4 | def test_gradle_build(project: Project) -> None: 5 | """ 6 | Run a gradle build 7 | """ 8 | 9 | assert project.gradle.build() is None 10 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "python.testing.pytestArgs": [ 3 | "tests", 4 | ], 5 | "python.testing.unittestEnabled": false, 6 | "python.testing.pytestEnabled": true, 7 | "editor.defaultFormatter": "ms-python.black-formatter", 8 | "editor.insertSpaces": false 9 | } -------------------------------------------------------------------------------- /tests/shulkr/smoke/test_cli.py: -------------------------------------------------------------------------------- 1 | def test_exits_with_status_code_of_zero(run): 2 | print("Exitted with non-zero status code:") 3 | print(run.error) 4 | assert run.exit_code == 0 5 | 6 | 7 | def test_displays_error_for_no_versions(run): 8 | assert "No versions selected" in run.output 9 | -------------------------------------------------------------------------------- /tests/gradle/smoke/conftest.py: -------------------------------------------------------------------------------- 1 | from tempfile import TemporaryDirectory 2 | import pytest 3 | 4 | from gradle.project import Project 5 | 6 | 7 | @pytest.fixture 8 | def tempdir(): 9 | with TemporaryDirectory() as tempdir: 10 | yield tempdir 11 | 12 | 13 | @pytest.fixture 14 | def project(tempdir): 15 | yield Project.init(tempdir) 16 | -------------------------------------------------------------------------------- /sample-git-hooks/pre-commit: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # Remove all git environment variables, because the tests need to run git 4 | # commands for another repo 5 | unset "${!GIT_@}" 6 | 7 | if [ "$skip_ci" != "true" ] 8 | then 9 | for script in lint test-unit test-smoke 10 | do 11 | pipenv run $script 12 | if [ $? -ne 0 ]; then exit 1; fi; 13 | done 14 | fi 15 | 16 | -------------------------------------------------------------------------------- /sample-git-hooks/pre-push: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # Remove all git environment variables, because the tests need to run git 4 | # commands for another repo 5 | unset "${!GIT_@}" 6 | 7 | if [ "$skip_ci" != "true" ] 8 | then 9 | for script in lint test-unit test-smoke test-func 10 | do 11 | pipenv run $script 12 | if [ $? -ne 0 ]; then exit 1; fi; 13 | done 14 | fi 15 | -------------------------------------------------------------------------------- /src/mint/__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | Internal git wrapper for Python 3 | 4 | The goal of Mint is to provide a Pythonic interface to run git commands. We 5 | were previously using gitpython, but we are now migrating to Mint because 6 | gitpython's use of git's plumbing commands can lead to unexpected results. To 7 | ease the migration process, Mint's API was designed to be very similar to that 8 | of gitpython. 9 | """ 10 | -------------------------------------------------------------------------------- /tests/mint/smoke/test_repo.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | 4 | class TestRepo: 5 | def test_init_creates_git_directory(self, repo): 6 | git_dir = os.path.join(repo.path, ".git") 7 | assert os.path.exists(git_dir) 8 | 9 | def test_clone_creates_git_directory(self, shallow_cloned_repo): 10 | git_dir = os.path.join(shallow_cloned_repo.path, ".git") 11 | assert os.path.exists(git_dir) 12 | -------------------------------------------------------------------------------- /.flake8: -------------------------------------------------------------------------------- 1 | [flake8] 2 | exclude = shulkr/DecompilerMC 3 | extend-ignore = 4 | # Indentation — black handles 5 | E1 6 | W1 7 | # Whitespace — black handles 8 | E2 9 | W2 10 | # Blank lines — black handles 11 | E3 12 | W3 13 | # Line length — black handles 14 | E5 15 | W5 16 | per-file-ignores = 17 | # line too long 18 | tests/*: E501 19 | 20 | max-line-length = 88 21 | # continuation-style = hanging 22 | -------------------------------------------------------------------------------- /.github/workflows/check-on-push.yml: -------------------------------------------------------------------------------- 1 | name: check-on-push 2 | 3 | on: 4 | push: 5 | branches: 6 | - '**' 7 | tags-ignore: 8 | - 'v**' 9 | paths-ignore: 10 | - '**.md' 11 | pull_request: 12 | workflow_dispatch: 13 | 14 | jobs: 15 | check: 16 | strategy: 17 | matrix: 18 | os: [ubuntu-latest, macOS-latest, windows-latest] 19 | 20 | uses: ./.github/workflows/check.yml 21 | with: 22 | os: ${{ matrix.os }} 23 | -------------------------------------------------------------------------------- /tests/command/smoke/conftest.py: -------------------------------------------------------------------------------- 1 | from tempfile import TemporaryDirectory 2 | import pytest 3 | 4 | from mint.repo import Repo 5 | 6 | from command import Command 7 | 8 | 9 | @pytest.fixture 10 | def tempdir(): 11 | with TemporaryDirectory() as tempdir: 12 | yield tempdir 13 | 14 | 15 | @pytest.fixture 16 | def repo(tempdir): 17 | yield Repo.init(tempdir) 18 | 19 | 20 | @pytest.fixture 21 | def git(repo): 22 | return Command("git", working_dir=repo.path) 23 | -------------------------------------------------------------------------------- /tests/mint/smoke/conftest.py: -------------------------------------------------------------------------------- 1 | from tempfile import TemporaryDirectory 2 | import pytest 3 | 4 | from mint.repo import Repo 5 | 6 | 7 | @pytest.fixture 8 | def tempdir(): 9 | with TemporaryDirectory() as tempdir: 10 | yield tempdir 11 | 12 | 13 | @pytest.fixture 14 | def repo(tempdir): 15 | yield Repo.init(tempdir) 16 | 17 | 18 | @pytest.fixture 19 | def shallow_cloned_repo(tempdir): 20 | yield Repo.clone("https://github.com/clabe45/shulkr.git", tempdir, depth=1) 21 | -------------------------------------------------------------------------------- /tests/shulkr/unit/test_repo.py: -------------------------------------------------------------------------------- 1 | from shulkr.repo import init_repo 2 | 3 | 4 | def test_init_repo_returns_true_if_repo_exists(mocker, empty_repo): 5 | mocker.patch("shulkr.repo.click") 6 | mocker.patch("shulkr.repo.Repo") 7 | 8 | assert init_repo(empty_repo.path) 9 | 10 | 11 | def test_init_repo_returns_false_if_repo_directory_does_not_exist(mocker): 12 | mocker.patch("shulkr.repo.click") 13 | mocker.patch("shulkr.repo.Repo", side_effect=FileNotFoundError) 14 | 15 | assert not init_repo("/tmp/does-not-exist") 16 | -------------------------------------------------------------------------------- /src/java/blob.py: -------------------------------------------------------------------------------- 1 | import os 2 | from typing import Optional 3 | 4 | import git 5 | 6 | 7 | def get_blob(repo: git.Repo, commit: Optional[git.Commit], path: str) -> bytes: 8 | if commit is None: 9 | p = os.path.join(repo.working_tree_dir, path) 10 | with open(p, "r") as f: 11 | return f.read() 12 | 13 | parts = path.split("/") 14 | curr = commit.tree 15 | 16 | while len(parts) > 0: 17 | name = parts.pop(0) 18 | curr = curr[name] 19 | 20 | return curr.data_stream.read().decode() 21 | -------------------------------------------------------------------------------- /src/shulkr/compatibility.py: -------------------------------------------------------------------------------- 1 | from mint.error import GitError 2 | 3 | from shulkr.config import config_exists 4 | from shulkr.repo import get_repo 5 | 6 | 7 | def _repo_has_commits() -> bool: 8 | try: 9 | get_repo().git.rev_parse("HEAD") 10 | return True 11 | 12 | except GitError: 13 | return False 14 | 15 | 16 | def is_compatible() -> bool: 17 | """ 18 | Check if the current repo is compatible with the current shulkr version. 19 | """ 20 | 21 | repo = get_repo() 22 | return config_exists(repo.path) or not _repo_has_commits() 23 | -------------------------------------------------------------------------------- /tests/mint/unit/test_repo.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from mint.repo import Repo 4 | 5 | 6 | @pytest.fixture(autouse=True) 7 | def fs(mocker) -> None: 8 | mocker.patch("mint.repo.os.path.exists", return_value=True) 9 | mocker.patch("mint.repo.os.path.isfile", return_value=False) 10 | 11 | 12 | @pytest.fixture(autouse=True) 13 | def command(mocker) -> None: 14 | mocker.patch("mint.repo.Command") 15 | 16 | 17 | class TestRepo: 18 | def test_clone_returns_repo_with_same_path(self): 19 | repo = Repo.clone("bar.git", "bar") 20 | assert repo.path == "bar" 21 | -------------------------------------------------------------------------------- /src/gradle/project.py: -------------------------------------------------------------------------------- 1 | from gradle.command import Gradle 2 | 3 | 4 | class Project: 5 | def __init__(self, project_dir: str, capture_output=False) -> None: 6 | self.project_dir = project_dir 7 | self.gradle = Gradle(self.project_dir, capture_output=capture_output) 8 | 9 | @staticmethod 10 | def init(project_dir: str, capture_output=False) -> "Project": 11 | """ 12 | Create a new gradle project 13 | """ 14 | 15 | project = Project(project_dir, capture_output=capture_output) 16 | project.gradle.init() 17 | return project 18 | -------------------------------------------------------------------------------- /docs/usage-guidelines.md: -------------------------------------------------------------------------------- 1 | # Usage Guidelines 2 | 3 | **No warranties. If Shulkr does not work for you, or causes any damage, it's 4 | your problem. Use it at your own risk.** 5 | 6 | You are allowed to: 7 | - Use Shulkr to decompile the Minecraft client and server jar files. 8 | 9 | You are **not** allowed to: 10 | - Use Shulkr to do anything that violates Mojangs terms of use for Minecraft. 11 | - Release the decompiled source code of Minecraft in any way. 12 | - Release Minecraft versions or modifications that allow you to play without having bought Minecraft from Mojang. 13 | - Use Shulkr to create clients that are used for griefing or exploiting server bugs. 14 | -------------------------------------------------------------------------------- /tests/gradle/unit/test_project.py: -------------------------------------------------------------------------------- 1 | from gradle.project import Project 2 | 3 | 4 | class TestProject: 5 | def test__init__creates_gradle_instance(self, mocker): 6 | """ 7 | Test Project constructor 8 | """ 9 | 10 | Gradle = mocker.patch("gradle.project.Gradle") 11 | 12 | Project(".", capture_output=True) 13 | 14 | Gradle.assert_called_once_with(".", capture_output=True) 15 | 16 | def test__init__sets_project_dir(self, mocker): 17 | """ 18 | Test Project constructor 19 | """ 20 | 21 | mocker.patch("gradle.project.Gradle") 22 | 23 | project = Project(".") 24 | 25 | assert project.project_dir == "." 26 | -------------------------------------------------------------------------------- /tests/command/smoke/test_command.py: -------------------------------------------------------------------------------- 1 | class TestCommand: 2 | def test_getattr_returns_git_output(self, git, repo): 3 | # Make one commit so git knows which branch is checked out 4 | git.commit(message="dummy commit", allow_empty=True) 5 | 6 | # Now, make sure the result of 'git status' is correct 7 | branch = git.rev_parse("HEAD", abbrev_ref=True) 8 | assert ( 9 | git.status() == f"On branch {branch}\nnothing to commit, working tree clean" 10 | ) 11 | 12 | def test_commit_messages_with_spaces_are_not_wrapped_with_quotes(self, git, repo): 13 | git.commit(message="dummy commit", allow_empty=True) 14 | 15 | assert git.log("--format=%B") == "dummy commit" 16 | -------------------------------------------------------------------------------- /tests/shulkr/unit/test_compatibility.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | import shulkr 4 | from shulkr.compatibility import is_compatible 5 | 6 | 7 | @pytest.fixture(autouse=True) 8 | def mock_all(mocker): 9 | mocker.patch("shulkr.compatibility.config_exists") 10 | 11 | 12 | def test_is_compatible_with_no_config_and_empty_repo_returns_true(empty_repo): 13 | shulkr.compatibility.config_exists.return_value = False 14 | assert is_compatible() 15 | 16 | 17 | def test_is_compatible_with_no_config_and_nonempty_repo_returns_false(nonempty_repo): 18 | shulkr.compatibility.config_exists.return_value = False 19 | assert not is_compatible() 20 | 21 | 22 | def test_is_compatible_with_config_and_nonempty_repo_returns_true(nonempty_repo): 23 | shulkr.compatibility.config_exists.return_value = True 24 | assert is_compatible() 25 | -------------------------------------------------------------------------------- /src/mint/README.md: -------------------------------------------------------------------------------- 1 | # Mint (Git) 2 | 3 | > internal git wrapper 4 | 5 | The goal of Mint is to provide a Pythonic interface to run git commands. We 6 | were previously using gitpython, but we are now migrating to Mint because 7 | gitpython's use of git's plumbing commands can lead to unexpected results. To 8 | ease the migration process, Mint's API was designed to be very similar to that 9 | of gitpython. 10 | 11 | ## Usage 12 | 13 | Preparing the repo: 14 | ```python 15 | # Use an existing repo 16 | repo = Repo(PATH) 17 | 18 | # Create an empty repo 19 | repo = Repo.init(PATH) 20 | 21 | # Clone a repo 22 | repo = Repo.clone(REMOTE_URL, DESTINATION) 23 | ``` 24 | 25 | Using the repo: 26 | ```python 27 | repo.git.commit('src', message='Some commit') # or m='Some commit' 28 | ``` 29 | 30 | ## Known Issues 31 | 32 | - Quotes in `--key=value` style Git arguments are treated literally. At least on 33 | Linux, Git seems to ignore quotes some of the time (more investigation needed). 34 | -------------------------------------------------------------------------------- /tests/shulkr/functional/test_file_system.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | 4 | def test_when_running_with_yarn_repo_only_contains_git_gitignore_yarn_and_src_directories( 5 | run_yarn, 6 | ): 7 | assert set(os.listdir(run_yarn.repo_path)) == set( 8 | [".git", ".gitignore", ".shulkr", "yarn", "src"] 9 | ) 10 | 11 | 12 | def test_when_running_with_yarn_src_directory_is_not_empty(run_yarn): 13 | src_dir = os.path.join(run_yarn.repo_path, "src") 14 | assert len(os.listdir(src_dir)) > 0 15 | 16 | 17 | def test_when_running_with_decompilermc_repo_only_contains_git_gitignore_yarn_client_and_server_directories( 18 | run_mojang, 19 | ): 20 | assert set(os.listdir(run_mojang.repo_path)) == set( 21 | [".git", ".gitignore", ".shulkr", "DecompilerMC", "src"] 22 | ) 23 | 24 | 25 | def test_when_running_with_decompilermc_src_directory_is_not_empty(run_mojang): 26 | src_dir = os.path.join(run_mojang.repo_path, "src") 27 | assert len(os.listdir(src_dir)) > 0 28 | -------------------------------------------------------------------------------- /src/shulkr/gitignore.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | import click 4 | 5 | from shulkr.repo import get_repo 6 | 7 | 8 | def _gitignore_path() -> str: 9 | repo = get_repo() 10 | 11 | return os.path.join(repo.path, ".gitignore") 12 | 13 | 14 | def _create_gitignore() -> None: 15 | click.echo("Creating gitignore") 16 | 17 | repo = get_repo() 18 | 19 | with open(_gitignore_path(), "w+") as gitignore: 20 | to_ignore = ["yarn", "DecompilerMC"] 21 | gitignore.write("\n".join(to_ignore) + "\n") 22 | 23 | repo.git.add(".gitignore") 24 | repo.git.commit(message="add .gitignore") 25 | 26 | 27 | def ensure_gitignore_exists() -> bool: 28 | """ 29 | Create and commit a .gitignore file if one does not exist 30 | 31 | Returns: 32 | bool: True if a .gitignore file was found. False if one was created. 33 | """ 34 | 35 | if not os.path.isfile(_gitignore_path()): 36 | _create_gitignore() 37 | return False 38 | 39 | else: 40 | return True 41 | -------------------------------------------------------------------------------- /src/shulkr/repo.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import click 4 | 5 | from mint.repo import NoSuchRepoError, Repo 6 | 7 | 8 | def init_repo(repo_path: str) -> bool: 9 | """ 10 | Load information about the current shulkr/git repo 11 | 12 | Must be called before get_repo() 13 | 14 | Args: 15 | repo_path (str): Path to the working tree of the repo 16 | 17 | Returns: 18 | bool: True if a repo was found, False if a new repo was created 19 | loaded. 20 | """ 21 | 22 | global repo 23 | 24 | try: 25 | repo = Repo(repo_path) 26 | 27 | # The repo already exists 28 | return True 29 | 30 | except FileNotFoundError: 31 | click.echo("Initializing git") 32 | repo = Repo.init(repo_path) 33 | 34 | except NotADirectoryError: 35 | click.echo("Initializing git") 36 | repo = Repo.init(repo_path) 37 | 38 | except NoSuchRepoError: 39 | click.echo("Initializing git") 40 | repo = Repo.init(repo_path) 41 | 42 | # We created the repo 43 | return False 44 | 45 | 46 | def get_repo() -> Repo: 47 | return repo 48 | 49 | 50 | repo = None 51 | -------------------------------------------------------------------------------- /Pipfile: -------------------------------------------------------------------------------- 1 | [[source]] 2 | url = "https://pypi.org/simple" 3 | verify_ssl = true 4 | name = "pypi" 5 | 6 | [packages] 7 | gitpython = "*" 8 | javalang = "*" 9 | unidiff = "*" 10 | requests = "*" 11 | toml = "*" 12 | click = "*" 13 | # Required by click for styling on Windows 14 | colorama = "*" 15 | 16 | [dev-packages] 17 | pytest = "*" 18 | pytest-mock = "*" 19 | twine = "*" 20 | flake8 = "*" 21 | bump2version = "*" 22 | keepachangelog = "*" 23 | python-lsp-server = "*" 24 | pyls-mypy = {git = "https://github.com/tomv564/pyls-mypy.git"} 25 | # Needed to run tests on windows 26 | atomicwrites = "*" 27 | black = "*" 28 | 29 | [requires] 30 | python_version = "3.9" 31 | 32 | [scripts] 33 | setup = "pip install -e ." 34 | start = "python -m shulkr" 35 | test-unit = "python -m pytest tests/command/unit tests/gradle/unit tests/mint/unit tests/minecraft/unit tests/shulkr/unit" 36 | test-smoke = "python -m pytest tests/command/smoke tests/gradle/smoke tests/mint/smoke tests/shulkr/smoke" 37 | test-func = "python -m pytest tests/java/functional tests/shulkr/functional" 38 | lint = "python -m flake8 src" 39 | format = "black ." 40 | profile = "python -m tests.profile" 41 | bump = "python -m scripts.bump" 42 | build = "python setup.py clean --all sdist bdist_wheel" 43 | publish = "python -m twine upload dist/*" 44 | publish-test = "python -m twine upload --repository testpypi dist/*" 45 | -------------------------------------------------------------------------------- /src/gradle/command.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | from command import Command 4 | 5 | 6 | class Gradle(Command): 7 | # `capture_output` is set to False by default because gradle commands can 8 | # be very verbose and can cause the command to hang on windows 9 | def __init__(self, project_dir: str, capture_output=False) -> None: 10 | exec = Gradle._executable(project_dir) 11 | super().__init__(exec, working_dir=project_dir, capture_output=capture_output) 12 | 13 | def __getattr__(self, name: str): 14 | super_func = super().__getattr__(name) 15 | 16 | def func(*args, **kwargs): 17 | return super_func( 18 | name, 19 | *args, 20 | # `--quiet` prevents the commands from being run interactively 21 | quiet=True, 22 | **kwargs 23 | ) 24 | 25 | return func 26 | 27 | @staticmethod 28 | def _executable(project_dir: str) -> bool: 29 | """ 30 | Get the path to gradlew or gradle (if gradlew is not found) 31 | """ 32 | 33 | gradlew_exec = os.path.join( 34 | os.path.abspath(project_dir), 35 | "gradlew.bat" if os.name == "nt" else "gradlew", 36 | ) 37 | if os.path.exists(gradlew_exec): 38 | return gradlew_exec 39 | 40 | return "gradle" 41 | -------------------------------------------------------------------------------- /tests/gradle/unit/test_command.py: -------------------------------------------------------------------------------- 1 | import os 2 | from gradle.command import Gradle 3 | 4 | 5 | class TestGradle: 6 | def test__init__without_gradlew(self, mocker): 7 | """ 8 | Gradle constructor 9 | """ 10 | 11 | Command__init__ = mocker.patch("gradle.command.Command.__init__") 12 | mocker.patch("gradle.command.os.path.exists", return_value=False) 13 | 14 | Gradle(".", capture_output=True) 15 | 16 | Command__init__.assert_called_once_with( 17 | "gradle", working_dir=".", capture_output=True 18 | ) 19 | 20 | def test__init__with_gradlew(self, mocker): 21 | """ 22 | Gradle constructor 23 | """ 24 | 25 | gradlew_path = "path/to/gradlew.bat" if os.name == "nt" else "path/to/gradlew" 26 | 27 | Command__init__ = mocker.patch("gradle.command.Command.__init__") 28 | mocker.patch("gradle.command.os.path.exists", return_value=True) 29 | mocker.patch("gradle.command.os.path.join", return_value=gradlew_path) 30 | 31 | Gradle(".", capture_output=True) 32 | 33 | Command__init__.assert_called_once_with( 34 | # Mocking `os.name` causes pytest to crash on unix, so we have to 35 | # check the os name 36 | gradlew_path, 37 | working_dir=".", 38 | capture_output=True, 39 | ) 40 | -------------------------------------------------------------------------------- /.github/workflows/check.yml: -------------------------------------------------------------------------------- 1 | name: check 2 | 3 | on: 4 | workflow_dispatch: 5 | workflow_call: 6 | inputs: 7 | os: 8 | description: 'Operating system' 9 | required: true 10 | type: string 11 | 12 | jobs: 13 | check: 14 | runs-on: ${{ inputs.os }} 15 | 16 | steps: 17 | - name: Set up java 18 | uses: actions/setup-java@v3 19 | with: 20 | distribution: 'adopt' 21 | java-version: '17' 22 | 23 | - name: Check out repository code 24 | uses: actions/checkout@v2 25 | with: 26 | submodules: true 27 | 28 | - name: Set up python 29 | uses: actions/setup-python@v2 30 | with: 31 | python-version: '3.9' 32 | 33 | - name: Install pipenv 34 | run: pip install pipenv 35 | 36 | - name: Install dependencies 37 | run: pipenv install -d 38 | 39 | - name: Install internal packages 40 | run: pipenv run setup 41 | 42 | - name: Lint code 43 | run: pipenv run lint 44 | 45 | - name: Run unit tests 46 | run: pipenv run test-unit 47 | 48 | - name: Use fake git credentials 49 | run: | 50 | git config --global user.email "example@xyz.com" 51 | git config --global user.name "Quentin Beck" 52 | 53 | - name: Run smoke tests 54 | run: pipenv run test-smoke 55 | 56 | - name: Run functional tests 57 | run: pipenv run test-func 58 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | import os.path 2 | from setuptools import find_packages, setup 3 | 4 | 5 | script_dir = os.path.dirname(os.path.realpath(__file__)) 6 | with open(os.path.join(script_dir, "README.md"), "r") as f: 7 | README = f.read() 8 | 9 | classifiers = [ 10 | "Development Status :: 3 - Alpha", 11 | "Environment :: Console", 12 | "Intended Audience :: Developers", 13 | "Intended Audience :: Science/Research", 14 | "License :: OSI Approved :: Apache Software License", 15 | "Operating System :: OS Independent", 16 | "Programming Language :: Python", 17 | "Programming Language :: Python :: 3", 18 | "Topic :: Games/Entertainment", 19 | "Topic :: Scientific/Engineering", 20 | "Topic :: Software Development :: Version Control :: Git", 21 | "Topic :: Utilities", 22 | ] 23 | 24 | setup( 25 | name="shulkr", 26 | version="0.7.2", 27 | description="Diff decompiled versions of Minecraft", 28 | long_description=README, 29 | long_description_content_type="text/markdown", 30 | url="https://github.com/clabe45/shulkr", 31 | author="Caleb Sacks", 32 | license="GPLv3", 33 | classifiers=classifiers, 34 | keywords=["minecraft", "git", "decompile", "game"], 35 | package_dir={"": "src"}, 36 | packages=find_packages(where="src"), 37 | py_modules=["colorama", "java", "minecraft", "mint", "shulkr"], 38 | install_requires=["gitpython", "javalang", "unidiff", "requests", "toml", "click"], 39 | entry_points={"console_scripts": ["shulkr=shulkr.__main__:main"]}, 40 | ) 41 | -------------------------------------------------------------------------------- /scripts/bump/__main__.py: -------------------------------------------------------------------------------- 1 | from mint.repo import Repo 2 | import keepachangelog 3 | 4 | 5 | CHANGELOG_PATH = "docs/changelog.md" 6 | 7 | 8 | def current_version(): 9 | changes = keepachangelog.to_dict(CHANGELOG_PATH) 10 | return sorted(changes.keys())[-1] 11 | 12 | 13 | def main(): 14 | repo = Repo(".") 15 | 16 | old_version = current_version() 17 | 18 | # Release to get predicted version 19 | keepachangelog.release(CHANGELOG_PATH) 20 | predicted_version = current_version() 21 | 22 | # Confirm version 23 | user_input = input("New version [{}]: ".format(predicted_version)) 24 | new_version = user_input or predicted_version 25 | 26 | # Undo temporary release 27 | repo.git.restore(CHANGELOG_PATH) 28 | 29 | # Release with confirmed version 30 | keepachangelog.release(CHANGELOG_PATH, new_version) 31 | 32 | # Update setup.py 33 | with open("setup.py", "r") as setuppy: 34 | setuppy_code = setuppy.read() 35 | new_setuppy_code = setuppy_code.replace(old_version, new_version) 36 | with open("setup.py", "w") as setuppy: 37 | setuppy.write(new_setuppy_code) 38 | 39 | # Commit to git 40 | commit_message = f"chore(*): release version {new_version}\n\nBump version {old_version} → {new_version}" 41 | repo.git.commit(CHANGELOG_PATH, "setup.py", message=commit_message) 42 | repo.git.tag(f"v{new_version}", annotate=True, message=f"version {new_version}") 43 | 44 | print(f"Bumped version {old_version} → {new_version}") 45 | 46 | 47 | if __name__ == "__main__": 48 | main() 49 | -------------------------------------------------------------------------------- /tests/shulkr/unit/test_gitignore.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | from shulkr.gitignore import ensure_gitignore_exists 4 | 5 | 6 | def test_ensure_gitignore_exists_opens_gitignore_for_writing_if_it_does_not_exist( 7 | mocker, empty_repo 8 | ): 9 | mocker.patch("shulkr.gitignore.click") 10 | mocker.patch("shulkr.gitignore.os.path.isfile", return_value=False) 11 | open_ = mocker.patch("shulkr.gitignore.open") 12 | 13 | ensure_gitignore_exists() 14 | 15 | gitignore_path = os.path.join(empty_repo.path, ".gitignore") 16 | open_.assert_called_once_with(gitignore_path, "w+") 17 | 18 | 19 | def test_ensure_gitignore_exists_does_not_open_gitignore_if_it_does_exist( 20 | mocker, empty_repo 21 | ): 22 | mocker.patch("shulkr.gitignore.click") 23 | mocker.patch("shulkr.gitignore.os.path.isfile", return_value=True) 24 | open_ = mocker.patch("shulkr.gitignore.open") 25 | 26 | ensure_gitignore_exists() 27 | 28 | open_.assert_not_called() 29 | 30 | 31 | def test_ensure_gitignore_exists_returns_false_if_it_does_not_exist(mocker, empty_repo): 32 | mocker.patch("shulkr.gitignore.click") 33 | mocker.patch("shulkr.gitignore.os.path.isfile", return_value=False) 34 | mocker.patch("shulkr.gitignore.open") 35 | 36 | assert not ensure_gitignore_exists() 37 | 38 | 39 | def test_ensure_gitignore_exists_returns_true_if_it_does_exist(mocker, empty_repo): 40 | mocker.patch("shulkr.gitignore.click") 41 | mocker.patch("shulkr.gitignore.os.path.isfile", return_value=True) 42 | mocker.patch("shulkr.gitignore.open") 43 | 44 | assert ensure_gitignore_exists() 45 | -------------------------------------------------------------------------------- /tests/shulkr/functional/minecraft/test_version.py: -------------------------------------------------------------------------------- 1 | from mint.repo import Repo 2 | from minecraft.version import Version 3 | 4 | from shulkr.version import get_latest_generated_version 5 | 6 | 7 | def test_get_latest_generated_version_with_repo_with_two_versions_returns_newer_version( 8 | repo: Repo, 9 | ): 10 | # Add two tagged commits (Minecraft versions) 11 | repo.git.commit(message="1.17", allow_empty=True) 12 | repo.git.tag("1.17") 13 | 14 | repo.git.commit(message="1.18", allow_empty=True) 15 | repo.git.tag("1.18") 16 | 17 | # The latest generated version from HEAD should be 1.18 18 | assert get_latest_generated_version() == Version.of("1.18") 19 | 20 | 21 | def test_get_latest_generated_version_with_repo_with_two_versions_after_checking_out_older_version_returns_newer_version( 22 | repo: Repo, 23 | ): 24 | # Add two tagged commits (Minecraft versions) 25 | repo.git.commit(message="1.17", allow_empty=True) 26 | repo.git.tag("1.17") 27 | 28 | repo.git.commit(message="1.18", allow_empty=True) 29 | repo.git.tag("1.18") 30 | 31 | # Get the name of the current (only) branch 32 | orig_branch = repo.git.rev_parse("HEAD", abbrev_ref=True) 33 | 34 | # Now, check out 1.17 (this used to cause get_latest_generated_version to 35 | # return 1.17 instead of 1.18) 36 | repo.git.checkout("1.17") 37 | 38 | # Return to 1.18, without checking it out directly (important) 39 | repo.git.checkout(orig_branch) 40 | 41 | # The latest generated version from HEAD should be 1.18 42 | assert get_latest_generated_version() == Version.of("1.18") 43 | -------------------------------------------------------------------------------- /tests/shulkr/profile/profile_java.py: -------------------------------------------------------------------------------- 1 | from timeit import timeit 2 | 3 | from java import get_renamed_variables 4 | 5 | 6 | def wrap_in_class(code: str) -> str: 7 | return ( 8 | """package foo; 9 | 10 | class Foo { 11 | public static void main(String[] args) { 12 | %s 13 | } 14 | } 15 | """ 16 | % code 17 | ) 18 | 19 | 20 | def profile_get_renamed_variables(): 21 | SAMPLES = [ 22 | ("int x = 0;", "int x = 0;"), 23 | ("int x = 0;", "int y = 0;"), 24 | ("int x, y, z;", "int a, b, c;"), 25 | ("int x; int y; int z;", "int a; int b; int c;"), 26 | ( 27 | "if (true) { int x = 0; } else { int x = 0; }", 28 | "if (true) { int x = 0; } else { int x = 0; }", 29 | ), 30 | ( 31 | "; ".join([f"int x{i}" for i in range(100)]) + ";", 32 | "; ".join([f"int x{i}" for i in range(100)]) + ";", 33 | ), 34 | ( 35 | "; ".join([f"int x{i}" for i in range(100)]) + ";", 36 | "; ".join([f"int y{i}" for i in range(100)]) + ";", 37 | ), 38 | ] 39 | 40 | total_time = 0.0 41 | 42 | for before, after in SAMPLES: 43 | print("---------------") 44 | print(before, end="\n") 45 | print(after, end="\n") 46 | before_wrapped = wrap_in_class(before) 47 | after_wrapped = wrap_in_class(after) 48 | duration = timeit( 49 | lambda: get_renamed_variables(before_wrapped, after_wrapped), number=100 50 | ) 51 | total_time += duration 52 | print(f"{duration}s", end="\n\n") 53 | 54 | print("---------------") 55 | print(f"{total_time}s") 56 | -------------------------------------------------------------------------------- /src/shulkr/cli.py: -------------------------------------------------------------------------------- 1 | import sys 2 | from typing import List 3 | 4 | import click 5 | 6 | from shulkr.app import run 7 | 8 | 9 | @click.command( 10 | name="shulkr", help="Generate multiple versions of the Minecraft source code" 11 | ) 12 | @click.help_option("-h", "--help", is_flag=True, help="Show this message and exit") 13 | @click.option( 14 | "--mappings", 15 | "-p", 16 | type=click.Choice(["yarn", "mojang"]), 17 | default="yarn", 18 | help="Mappings for deobfuscation (defaults to 'yarn')", 19 | ) 20 | @click.option( 21 | "--repo", 22 | "-p", 23 | type=click.Path(), 24 | default=".", 25 | help="Path to the Minecraft repo (defaults to the current working directory)", 26 | ) 27 | @click.option( 28 | "--message", 29 | "-m", 30 | type=str, 31 | default="version {}", 32 | help="Commit message template (defaults to 'version {}')", 33 | ) 34 | @click.option("--no-tags", "-T", is_flag=True, help="Do not tag commits") 35 | @click.option( 36 | "--undo-renamed-vars", 37 | "-u", 38 | is_flag=True, 39 | help=( 40 | "Revert local variables that were renamed in new versions to their " 41 | "original names (experimental)" 42 | ), 43 | ) 44 | @click.argument("versions", nargs=-1, type=click.STRING) 45 | def cli( 46 | versions: List[str], 47 | mappings: str, 48 | repo: str, 49 | message: str, 50 | no_tags: bool, 51 | undo_renamed_vars: bool, 52 | ) -> None: 53 | 54 | tags = not no_tags 55 | try: 56 | run(versions, mappings, repo, message, tags, undo_renamed_vars) 57 | 58 | except ValueError as e: 59 | click.secho(e, err=True, fg="red") 60 | sys.exit(2) 61 | 62 | except KeyboardInterrupt: 63 | click.echo("Aborted!", err=True) 64 | -------------------------------------------------------------------------------- /tests/minecraft/unit/conftest.py: -------------------------------------------------------------------------------- 1 | import os 2 | from unittest.mock import MagicMock 3 | 4 | import pytest 5 | 6 | from minecraft.version import Version, clear_manifest, load_manifest 7 | 8 | 9 | MANIFEST_DATA = { 10 | "latest": {"release": "1.20.5", "snapshot": "abcdef"}, 11 | "versions": [ 12 | {"type": "release", "id": "1.20.5"}, 13 | {"type": "snapshot", "id": "abcdef"}, 14 | {"type": "release", "id": "1.20.4"}, 15 | ], 16 | } 17 | 18 | 19 | class TestVersions: 20 | def __init__(self, v1_20_4: Version, snapshot: Version, v1_20_5: Version) -> None: 21 | self.v1_20_4 = v1_20_4 22 | self.snapshot = snapshot 23 | self.v1_20_5 = v1_20_5 24 | self.release = v1_20_5 25 | 26 | 27 | def create_repo(mocker, path: str): 28 | repo = MagicMock() 29 | 30 | # Make the repo directory useful for testing shulkr.minecraft.source 31 | repo.path = os.path.abspath(path) 32 | 33 | class MockGitCommand: 34 | def add(): 35 | pass 36 | 37 | def checkout(): 38 | pass 39 | 40 | def clean(): 41 | pass 42 | 43 | def commit(): 44 | pass 45 | 46 | def describe(): 47 | pass 48 | 49 | def fetch(): 50 | pass 51 | 52 | def reset(): 53 | pass 54 | 55 | def tag(): 56 | pass 57 | 58 | def rev_parse(): 59 | pass 60 | 61 | repo.git = mocker.create_autospec(MockGitCommand()) 62 | 63 | return repo 64 | 65 | 66 | @pytest.fixture 67 | def versions(): 68 | load_manifest(MANIFEST_DATA, earliest_supported_version_id="1.20.4") 69 | 70 | v1_20_4 = Version.of("1.20.4") 71 | snapshot = Version.of("abcdef") 72 | v1_20_5 = Version.of("1.20.5") 73 | 74 | yield TestVersions(v1_20_4, snapshot, v1_20_5) 75 | 76 | clear_manifest() 77 | -------------------------------------------------------------------------------- /.github/workflows/release-on-push-tag.yml: -------------------------------------------------------------------------------- 1 | name: release-on-push-tag 2 | 3 | on: 4 | push: 5 | tags: 6 | - 'v*' 7 | workflow_dispatch: 8 | 9 | jobs: 10 | check: 11 | strategy: 12 | matrix: 13 | os: [ubuntu-latest, macOS-latest, windows-latest] 14 | 15 | uses: ./.github/workflows/check.yml 16 | with: 17 | os: ${{ matrix.os }} 18 | 19 | release: 20 | needs: check 21 | runs-on: ubuntu-latest 22 | 23 | steps: 24 | - name: Check out repository code 25 | uses: actions/checkout@v2 26 | with: 27 | submodules: true 28 | 29 | - name: Set up python 30 | uses: actions/setup-python@v2 31 | with: 32 | python-version: '3.9' 33 | 34 | - name: Install pipenv 35 | run: pip install pipenv 36 | 37 | - name: Install dependencies 38 | run: pipenv install -d 39 | 40 | - name: Install internal packages 41 | run: pipenv run setup 42 | 43 | - name: Get version from tag 44 | id: tag_name 45 | run: | 46 | echo ::set-output name=current_version::${GITHUB_REF#refs/tags/v} 47 | shell: bash 48 | 49 | - name: Build distribution 50 | run: pipenv run build 51 | 52 | - name: Get changelog notes 53 | id: changelog_reader 54 | uses: mindsers/changelog-reader-action@v2 55 | with: 56 | version: ${{ steps.tag_name.outputs.current_version }} 57 | path: ./docs/changelog.md 58 | 59 | - name: Publish release to PyPi 60 | run: pipenv run publish -u __token__ -p ${{ secrets.PYPI_TOKEN }} 61 | 62 | - name: Publish release to GitHub 63 | uses: ncipollo/release-action@v1.10.0 64 | with: 65 | tag_name: ${{ github.ref }} 66 | release_name: ${{ github.ref }} 67 | body: ${{ steps.changelog_reader.outputs.changes }} 68 | -------------------------------------------------------------------------------- /tests/shulkr/smoke/conftest.py: -------------------------------------------------------------------------------- 1 | import subprocess 2 | import tempfile 3 | from typing import Generator, List 4 | 5 | from minecraft.version import clear_manifest, load_manifest 6 | from mint.repo import Repo 7 | 8 | import pytest 9 | 10 | 11 | class RunResult: 12 | def __init__(self, exit_code: int, output: str, error: str) -> None: 13 | """ 14 | Args: 15 | status_code (int): The exit code of the command. 16 | output (str): Stdout 17 | error (str): Stderr 18 | """ 19 | 20 | self.exit_code = exit_code 21 | self.output = output 22 | self.error = error 23 | 24 | 25 | @pytest.fixture(autouse=True) 26 | def manifest(): 27 | load_manifest() 28 | yield 29 | clear_manifest() 30 | 31 | 32 | @pytest.fixture 33 | def repo(mocker): 34 | tmp_dir = tempfile.TemporaryDirectory(prefix="shulkr-test") 35 | repo = Repo.init(tmp_dir.name) 36 | mocker.patch("shulkr.repo.repo", repo) 37 | 38 | yield repo 39 | 40 | # tmp_dir is removed when it goes out of scope 41 | 42 | 43 | def create_command(repo_path: str) -> List[str]: 44 | return ["pipenv", "run", "start", "-p", repo_path] 45 | 46 | 47 | @pytest.fixture(scope="session") 48 | def run() -> Generator[RunResult, None, None]: 49 | """ 50 | Run shulkr with no versions 51 | 52 | It is expected to display a warning message stating that no versions are 53 | selected. 54 | 55 | Yields: 56 | Stderr (if present), otherwise None 57 | """ 58 | 59 | with tempfile.TemporaryDirectory(prefix="shulkr") as repo_path: 60 | command = create_command(repo_path) 61 | p = subprocess.run(command, stdout=subprocess.PIPE, stderr=subprocess.PIPE) 62 | 63 | stdout = p.stdout.decode("utf-8") 64 | stderr = p.stderr.decode("utf-8") 65 | yield RunResult(p.returncode, stdout, stderr) 66 | -------------------------------------------------------------------------------- /src/mint/repo.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | import os 3 | 4 | import git 5 | from command import Command 6 | 7 | from mint.error import GitError 8 | 9 | 10 | class NoSuchRepoError(Exception): 11 | def __init__(self, path: str, *args: object) -> None: 12 | super().__init__(*args) 13 | 14 | self.path = path 15 | 16 | def __str__(self) -> str: 17 | return f"{self.path} is not a git repo" 18 | 19 | 20 | class Repo: 21 | """ 22 | Git repo 23 | 24 | Creating a Repo object: 25 | # Use an existing repo 26 | repo = Repo(PATH) 27 | 28 | # Create an empty repo 29 | repo = Repo.init(PATH) 30 | 31 | # Clone a repo 32 | repo = Repo.clone(REMOTE_URL, DESTINATION) 33 | 34 | Using a Repo object: 35 | repo.git.commit('src', message='Some commit') # or m='Some commit' 36 | """ 37 | 38 | def __init__(self, path: str, check_path=True) -> None: 39 | if check_path: 40 | Repo._ensure_repo_path_is_valid(path) 41 | 42 | self.path = path 43 | self.git = Command("git", working_dir=path, error=GitError) 44 | 45 | def to_gitpython(self) -> git.Repo: 46 | return git.Repo(self.path) 47 | 48 | @staticmethod 49 | def _ensure_repo_path_is_valid(path: str): 50 | if not os.path.exists(path): 51 | raise FileNotFoundError(path) 52 | 53 | # Make sure the path is a directory or a symlink 54 | if os.path.isfile(path): 55 | raise NotADirectoryError(path) 56 | 57 | git_dir = os.path.join(path, ".git") 58 | if not os.path.exists(git_dir) or os.path.isfile(git_dir): 59 | raise NoSuchRepoError(path) 60 | 61 | @staticmethod 62 | def init(path: str, **kwargs) -> Repo: 63 | if not os.path.exists(path): 64 | os.mkdir(path) 65 | 66 | repo = Repo(path, check_path=False) 67 | repo.git.init(**kwargs) 68 | return repo 69 | 70 | @staticmethod 71 | def clone(remote: str, dest: str, **kwargs) -> Repo: 72 | git = Command("git", error=GitError) 73 | git.clone(remote, dest, **kwargs) 74 | return Repo(dest) 75 | -------------------------------------------------------------------------------- /tests/shulkr/functional/test_git.py: -------------------------------------------------------------------------------- 1 | from mint.repo import Repo 2 | 3 | 4 | def test_git_repo_initiated(run_yarn): 5 | # Make sure this is a git repo. If it's not, an error will be thrown. 6 | Repo(run_yarn.repo_path) 7 | 8 | 9 | def test_commits_created(run_yarn): 10 | repo = Repo(run_yarn.repo_path) 11 | history = repo.git.rev_list("HEAD").splitlines() 12 | actual = [repo.git.log("--format=%B", commit, n=1) for commit in history] 13 | actual.reverse() 14 | 15 | # Calculate expected commit messages from versions 16 | expected = ["add .shulkr", "add .gitignore"] 17 | if run_yarn.undo_renamed_vars: 18 | expected.append(f"version {run_yarn.versions[0]}") 19 | expected.extend( 20 | [ 21 | f"version {version}\n\nRenamed variables reverted" 22 | for version in run_yarn.versions[1:] 23 | ] 24 | ) 25 | else: 26 | expected.extend([f"version {version}" for version in run_yarn.versions]) 27 | 28 | assert actual == expected 29 | 30 | 31 | def test_when_running_with_yarn_gitignore_and_src_are_tracked(run_yarn): 32 | repo = Repo(run_yarn.repo_path) 33 | 34 | # List files and directories that were changed directly under the root 35 | actual = set(repo.git.ls_tree("HEAD", name_only=True).splitlines()) 36 | expected = set([".gitignore", ".shulkr", "src"]) 37 | assert actual == expected 38 | 39 | 40 | def test_when_running_with_decompilermc_gitignore_src_are_tracked(run_mojang): 41 | repo = Repo(run_mojang.repo_path) 42 | 43 | # List files and directories that were changed directly under the root 44 | actual = set(repo.git.ls_tree("HEAD", name_only=True).splitlines()) 45 | expected = set([".gitignore", ".shulkr", "src"]) 46 | assert actual == expected 47 | 48 | 49 | def test_tags_created(run_yarn): 50 | repo = Repo(run_yarn.repo_path) 51 | actual = set(repo.git.tag(list=True).splitlines()) 52 | expected = set(run_yarn.versions) 53 | assert actual == expected 54 | 55 | 56 | def test_when_running_with_yarn_working_tree_clean(run_yarn): 57 | repo = Repo(run_yarn.repo_path) 58 | assert len(repo.git.status(porcelain=True)) == 0 59 | 60 | 61 | def test_when_running_with_decompilermc_working_tree_clean(run_mojang): 62 | repo = Repo(run_mojang.repo_path) 63 | assert len(repo.git.status(porcelain=True)) == 0 64 | -------------------------------------------------------------------------------- /src/shulkr/app.py: -------------------------------------------------------------------------------- 1 | import os 2 | import sys 3 | from typing import List 4 | 5 | import click 6 | from minecraft.version import NoSuchVersionError, Version, load_manifest 7 | 8 | from shulkr.compatibility import is_compatible 9 | from shulkr.config import init_config 10 | from shulkr.gitignore import ensure_gitignore_exists 11 | from shulkr.repo import init_repo 12 | from shulkr.version import create_version, get_latest_generated_version 13 | 14 | 15 | def run( 16 | versions: List[str], 17 | mappings: str, 18 | repo_path: str, 19 | message_template: str, 20 | tags: bool, 21 | undo_renamed_vars: bool, 22 | ) -> None: 23 | 24 | load_manifest() 25 | 26 | full_repo_path = os.path.join(os.getcwd(), repo_path) 27 | 28 | init_output = not init_repo(full_repo_path) 29 | 30 | if not is_compatible(): 31 | click.secho( 32 | "This repo is not compatible with the current version of shulkr - " 33 | + "please create a new repo or downgrade shulkr.", 34 | err=True, 35 | fg="yellow", 36 | ) 37 | sys.exit(4) 38 | 39 | init_output = ( 40 | not init_config( 41 | full_repo_path, mappings, message_template, tags, undo_renamed_vars 42 | ) 43 | or init_output 44 | ) 45 | init_output = not ensure_gitignore_exists() or init_output 46 | 47 | # If we printed anything in the initialization step, print a newline 48 | if init_output: 49 | click.echo() 50 | 51 | try: 52 | resolved_versions = Version.patterns( 53 | versions, latest_in_repo=get_latest_generated_version() 54 | ) 55 | 56 | except NoSuchVersionError as e: 57 | click.secho(e, err=True, fg="red") 58 | sys.exit(1) 59 | 60 | if len(resolved_versions) == 0: 61 | click.secho("No versions selected", color="yellow") 62 | sys.exit(0) 63 | 64 | if resolved_versions[0] < get_latest_generated_version(): 65 | click.secho( 66 | "The latest version in the repo is " 67 | + get_latest_generated_version().id 68 | + ", but you selected " 69 | + resolved_versions[0].id 70 | + ". Please select a version that is newer than the latest " 71 | + "version in the repo.", 72 | err=True, 73 | fg="red", 74 | ) 75 | sys.exit(3) 76 | 77 | for i, version in enumerate(resolved_versions): 78 | create_version(version) 79 | 80 | # Print line between the output of generating each version 81 | if i < len(resolved_versions) - 1: 82 | click.echo() 83 | -------------------------------------------------------------------------------- /tests/shulkr/functional/conftest.py: -------------------------------------------------------------------------------- 1 | import subprocess 2 | import tempfile 3 | from typing import List 4 | 5 | from minecraft.version import clear_manifest, load_manifest 6 | from mint.repo import Repo 7 | 8 | import pytest 9 | 10 | 11 | class RunParams: 12 | def __init__( 13 | self, versions: List[str], repo_path: str, undo_renamed_vars: bool 14 | ) -> None: 15 | 16 | self.versions = versions 17 | self.repo_path = repo_path 18 | self.undo_renamed_vars = undo_renamed_vars 19 | 20 | 21 | @pytest.fixture(autouse=True) 22 | def manifest(): 23 | load_manifest() 24 | yield 25 | clear_manifest() 26 | 27 | 28 | @pytest.fixture 29 | def repo(mocker): 30 | tmp_dir = tempfile.TemporaryDirectory(prefix="shulkr-test") 31 | repo = Repo.init(tmp_dir.name) 32 | mocker.patch("shulkr.repo.repo", repo) 33 | 34 | yield repo 35 | 36 | # tmp_dir is removed when it goes out of scope 37 | 38 | 39 | def create_command( 40 | versions: List[str], mappings: str, repo_path: str, undo_renamed_vars: bool 41 | ) -> List[str]: 42 | 43 | command = ["pipenv", "run", "start", "-p", repo_path, "--mappings", mappings] 44 | if undo_renamed_vars: 45 | command.append("-u") 46 | 47 | command.extend(versions) 48 | 49 | return command 50 | 51 | 52 | def _run(versions: List[str], mappings: str, undo_renamed_vars: bool) -> None: 53 | with tempfile.TemporaryDirectory(prefix="shulkr") as repo_path: 54 | command = create_command(versions, mappings, repo_path, undo_renamed_vars) 55 | p = subprocess.run(command, stderr=subprocess.PIPE) 56 | 57 | if p.returncode != 0: 58 | raise Exception(p.stderr.decode()) 59 | 60 | yield RunParams(versions, repo_path, undo_renamed_vars) 61 | 62 | 63 | @pytest.fixture( 64 | scope="session", 65 | params=[ 66 | # Testing every combination of mappings to undo_variable_renames will 67 | # take too long, so just mix and match 68 | (["1.17.1", "1.18"], "mojang", False), 69 | ], 70 | ) 71 | def run_mojang(request): 72 | versions, mappings, undo_variable_renames = request.param 73 | yield from _run(versions, mappings, undo_variable_renames) 74 | 75 | 76 | @pytest.fixture( 77 | scope="session", 78 | params=[ 79 | # Testing every combination of mappings to undo_variable_renames will 80 | # take too long, so just mix and match 81 | (["1.17.1", "1.18"], "yarn", True), 82 | ], 83 | ) 84 | def run_yarn(request): 85 | versions, mappings, undo_variable_renames = request.param 86 | yield from _run(versions, mappings, undo_variable_renames) 87 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Shulkr 2 | 3 | [![Discord](https://img.shields.io/discord/992622345334292542)](https://discord.gg/GDSft8kHWg) 4 | ![Check New Commits](https://github.com/clabe45/shulkr/actions/workflows/check.yml/badge.svg) 5 | [![PyPI version](https://badge.fury.io/py/shulkr.svg)](https://badge.fury.io/py/shulkr) 6 | 7 | Shulkr is a tool that decompiles multiple versions of Minecraft and commits each 8 | version to Git 9 | 10 | **Warning: You CANNOT publish any code generated by this tool. For more info, 11 | see the [usage guidelines].** 12 | 13 | ## Requirements 14 | 15 | - Git 16 | - Python 3 17 | - JDK (>= 17 for Minecraft 1.18 and above) 18 | 19 | ## Installation 20 | 21 | ``` 22 | pip install shulkr 23 | ``` 24 | 25 | ## Usage 26 | 27 | ```sh 28 | shulkr 1.16 1.17 1.18 29 | ``` 30 | 31 | This will generate a commit with the decompiled source code for Minecraft 1.16, 32 | 1.17 and 1.18 in the current working directory: 33 | 34 | ``` 35 | 204b37c (HEAD -> main, tag: 1.18) version 1.18 36 | 86dc440 (tag: 1.17) version 1.17 37 | 5d13494 (tag: 1.16) version 1.16 38 | ``` 39 | 40 | Note: It's okay to skip versions. Shulkr generates the complete source code for 41 | each version before committing to git, so you can include as many or as little 42 | intermediate versions as you would like. 43 | 44 | ## Version Patterns 45 | 46 | Ranges of versions can be specified with `..` and `...`: 47 | - `A..B` expands to all versions between `A` and `B` (inclusive), *not* 48 | including snapshots 49 | - `A...B` expands to all versions between `A` and `B` (inclusive), including 50 | snapshots 51 | 52 | `A` and/or `B` can be omitted, defaulting to the version after the most recent 53 | commit and the latest supported version, respectively. 54 | 55 | A *negative pattern* removes all matching versions that came before it. To 56 | negate a pattern, add `-`. The following pattern expands to all versions after 57 | `A`, up to and including `B` (the order is important): 58 | - `A...B -A` 59 | 60 | Note that you need to include `--` before the versions when using negative 61 | versions, so the argument parser knows that the negative version is not an 62 | option: 63 | 64 | ```sh 65 | shulkr -- ...1.19 -1.19 66 | ``` 67 | 68 | ## Options 69 | 70 | ### `--repo` / `-p` 71 | 72 | By default the source code is generated in the current working directory. To 73 | specify a different location: 74 | 75 | ```sh 76 | shulkr --repo minecraft-sources 1.17.. 77 | ``` 78 | 79 | If the directory does not exist, a new git repo will be created there. 80 | 81 | ### `--mappings` 82 | 83 | By default, Minecraft's bytecode is deobfuscated using [yarn's] mappings. You 84 | can also use `--mappings mojang` to use Mojang's official mappings. 85 | 86 | If left unspecified, the mappings used to generate the previous commit are 87 | detected. 88 | 89 | ### `--message` / `-m` 90 | 91 | This option lets you customize the commit message format: 92 | 93 | ```sh 94 | shulkr -m "Minecraft {}" 1.18-rc4 95 | ``` 96 | 97 | ### `--no-tags` / `-T` 98 | 99 | By default, each commit is tagged with the name of its Minecraft version. This 100 | can be disabled with `--no-tags`. 101 | 102 | ## Experimental Options 103 | 104 | ### `--undo-renamed-vars` / `-u` 105 | 106 | When this option is enabled, local variables that were renamed in new versions 107 | will be reverted to their original names. 108 | 109 | ## Changelog 110 | 111 | See the [changelog]. 112 | 113 | ## Contributing 114 | 115 | See the [contributing guide]. 116 | 117 | ## License 118 | 119 | Licensed under the Apache License, Version 2.0. 120 | 121 | [yarn's]: https://github.com/FabricMC/yarn 122 | [Fork]: https://github.com/clabe45/shulkr/fork 123 | [changelog]: ./docs/changelog.md 124 | [usage guidelines]: ./docs/usage-guidelines.md 125 | [contributing guide]: ./docs/contributing.md 126 | -------------------------------------------------------------------------------- /src/shulkr/version.py: -------------------------------------------------------------------------------- 1 | """ 2 | Module for management of Minecraft versions 3 | """ 4 | 5 | import os 6 | import shutil 7 | 8 | import click 9 | from java import undo_renames 10 | from minecraft.source import generate_sources 11 | from minecraft.version import Version 12 | from mint.error import GitError 13 | 14 | from shulkr.config import get_config 15 | from shulkr.repo import get_repo 16 | 17 | 18 | def _commit_version(version: Version) -> None: 19 | repo = get_repo() 20 | message_template = get_config().message_template 21 | 22 | commit_msg = message_template.strip().replace("{}", str(version)) 23 | if get_config().undo_renamed_vars and head_has_versions(): 24 | commit_msg += "\n\nRenamed variables reverted" 25 | 26 | repo.git.add("src") 27 | 28 | repo.git.commit(message=commit_msg) 29 | 30 | 31 | def _tag_version(version: Version) -> None: 32 | repo = get_repo() 33 | 34 | repo.git.tag(version) 35 | 36 | 37 | def create_version(version: Version) -> None: 38 | """ 39 | Generate the sources for a Minecraft version and commit to the repo 40 | 41 | Args: 42 | version (Version): Version to create 43 | undo_renamed_vars (bool): If set, this function will attempt to revert 44 | any variables that were renamed in the new version 45 | message_template (str): Template for commit messages ('{}'s will be 46 | replaced with the version name) 47 | tag (bool): If set, the commit will be tagged 48 | """ 49 | 50 | # 1. Generate source code for the current version 51 | click.secho(f"Generating sources for Minecraft {version}", bold=True) 52 | 53 | repo = get_repo() 54 | mappings = get_config().mappings 55 | repo_path = repo.path 56 | 57 | try: 58 | generate_sources(version, mappings, repo_path) 59 | except BaseException as e: 60 | # Undo src/ deletions 61 | if head_has_versions(): 62 | repo.git.restore("src") 63 | else: 64 | path = os.path.join(repo.path, "src") 65 | if os.path.exists(path): 66 | shutil.rmtree(path) 67 | 68 | raise e 69 | 70 | # 2. If there are any previous versions, undo the renamed variables 71 | if get_config().undo_renamed_vars and head_has_versions(): 72 | click.echo("Undoing renamed variables") 73 | undo_renames(get_repo().to_gitpython()) 74 | 75 | # 3. Commit the new version to git 76 | click.echo("Committing to git") 77 | _commit_version(version) 78 | 79 | # 4. Tag 80 | if get_config().tag: 81 | _tag_version(version) 82 | 83 | 84 | def head_has_versions() -> bool: 85 | """ 86 | Check if any versions have been generated on the current branch 87 | 88 | Raises: 89 | e: 90 | 91 | Returns: 92 | bool: True if at least one version was found on the current branch 93 | """ 94 | 95 | repo = get_repo() 96 | 97 | try: 98 | # List tags reachable by HEAD 99 | repo.git.describe(tags=True) 100 | 101 | # If we made it here, there is at least one tag. 102 | return True 103 | 104 | except GitError as e: 105 | if "fatal: No names found, cannot describe anything." in e.stderr: 106 | return False 107 | 108 | raise e 109 | 110 | 111 | def get_latest_generated_version() -> Version: 112 | """ 113 | Get the most recent version commit on the current branch 114 | 115 | Returns: 116 | Version: 117 | """ 118 | 119 | if not head_has_versions(): 120 | return None 121 | 122 | repo = get_repo() 123 | 124 | # Get most recent tag reachable by HEAD 125 | tag_name = repo.git.describe(tags=True) 126 | 127 | return Version.of(tag_name) 128 | -------------------------------------------------------------------------------- /src/command/__init__.py: -------------------------------------------------------------------------------- 1 | import os 2 | import shutil 3 | import subprocess 4 | from typing import Any, Dict, List 5 | 6 | 7 | class BaseCommandError(Exception): 8 | pass 9 | 10 | 11 | class CommandError(BaseCommandError): 12 | def __init__(self, command: str, stderr: str, *args: object) -> None: 13 | super().__init__(*args) 14 | 15 | self.command = command 16 | self.stderr = stderr 17 | 18 | def __str__(self) -> str: 19 | return f"{self.command}:\n{self.stderr}" 20 | 21 | 22 | class CommandNotFoundError(BaseCommandError): 23 | def __init__(self, command: str, *args: object) -> None: 24 | super().__init__(*args) 25 | 26 | self.command = command 27 | 28 | def __str__(self) -> str: 29 | return f"Command not found: {self.command}" 30 | 31 | 32 | class Command: 33 | """ 34 | Context for running git commands 35 | 36 | Sample usage: 37 | git = GitCommand(PATH_TO_REPO) 38 | if not git.status(porcelain=True): 39 | git.commit(message='empty commit', allow_empty=True) 40 | 41 | Currently, options that require an '=' between the key and the value must be 42 | supplied as positional arguments: 43 | git.log('--format=%B') 44 | """ 45 | 46 | def __init__( 47 | self, 48 | executabale: str, 49 | working_dir: str = None, 50 | capture_output: bool = True, 51 | error=CommandError, 52 | ) -> None: 53 | 54 | if not shutil.which(executabale): 55 | raise CommandNotFoundError(executabale) 56 | 57 | self._executable = executabale 58 | 59 | if working_dir is None: 60 | working_dir = os.getcwd() 61 | 62 | self._working_dir = working_dir 63 | self._capture_output = capture_output 64 | 65 | self._error = error 66 | 67 | def _run_command(self, command: List[str]) -> str: 68 | try: 69 | proc = subprocess.run( 70 | command, 71 | cwd=self._working_dir, 72 | check=True, 73 | capture_output=self._capture_output, 74 | text=True, 75 | ) 76 | 77 | if self._capture_output: 78 | return proc.stdout.strip() 79 | 80 | except subprocess.CalledProcessError as e: 81 | # Convert the error to to the user-specified error type 82 | raise self._error(command, e.stderr) from e 83 | 84 | def __getattr__(self, name: str): 85 | """ 86 | Return the specified git subcommand as a function 87 | 88 | Args: 89 | name (str): Name of the subcommand 90 | """ 91 | 92 | def func(*args, **kwargs): 93 | subcommand = name.replace("_", "-") 94 | 95 | command = self._raw_command(subcommand, args, kwargs) 96 | return self._run_command(command) 97 | 98 | return func 99 | 100 | @staticmethod 101 | def _format_option(key: str, value: Any) -> List[str]: 102 | option = key.replace("_", "-") 103 | prefix = "-" if len(option) == 1 else "--" 104 | 105 | if value is True: 106 | return [f"{prefix}{option}"] 107 | 108 | elif value is False: 109 | return [] 110 | 111 | else: 112 | # Non-boolean value 113 | return [f"{prefix}{option}", str(value)] 114 | 115 | def _raw_command( 116 | self, subcommand: str, args: List[Any], kwargs: Dict[str, Any] 117 | ) -> List[str]: 118 | 119 | options = [ 120 | token 121 | for key, value in kwargs.items() 122 | for token in Command._format_option(key, value) 123 | ] 124 | args = [str(arg) for arg in args] 125 | return [self._executable, subcommand, *options, *args] 126 | -------------------------------------------------------------------------------- /src/shulkr/config.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | import os 3 | 4 | import click 5 | import toml 6 | 7 | from shulkr.repo import get_repo 8 | 9 | 10 | class Config: 11 | def __init__( 12 | self, 13 | repo_path: str, 14 | mappings: str, 15 | message_template: str, 16 | tag: bool, 17 | undo_renamed_vars: bool, 18 | ) -> None: 19 | 20 | self.repo_path = repo_path 21 | self.mappings = mappings 22 | self.message_template = message_template 23 | self.tag = tag 24 | self.undo_renamed_vars = undo_renamed_vars 25 | 26 | def save(self) -> None: 27 | """ 28 | Write this configuration as TOML to the .shulkr file in the 29 | corresponding shulkr repo 30 | """ 31 | 32 | raw_config = { 33 | "mappings": self.mappings, 34 | "message": self.message_template, 35 | "tag": self.tag, 36 | "experimental": {"undo_renamed_vars": self.undo_renamed_vars}, 37 | # No need to store the repo path (since it is supplied to the CLI 38 | # and defaults to the CWD) 39 | } 40 | 41 | config_path = _config_path_for_repo(self.repo_path) 42 | with open(config_path, "w+") as config_file: 43 | toml.dump(raw_config, config_file) 44 | 45 | 46 | def _config_path_for_repo(repo_path: str) -> str: 47 | return os.path.join(repo_path, ".shulkr") 48 | 49 | 50 | def _load_config(repo_path: str) -> Config: 51 | config_path = _config_path_for_repo(repo_path) 52 | with open(config_path, "r") as config_file: 53 | raw_config = toml.load(config_file) 54 | 55 | return Config( 56 | repo_path=repo_path, 57 | mappings=raw_config["mappings"], 58 | message_template=raw_config["message"], 59 | tag=raw_config["tag"], 60 | undo_renamed_vars=raw_config["experimental"]["undo_renamed_vars"], 61 | ) 62 | 63 | 64 | def _commit_config() -> None: 65 | repo = get_repo() 66 | 67 | repo.git.add(".shulkr") 68 | repo.git.commit(message="add .shulkr") 69 | 70 | 71 | def _create_config( 72 | repo_path: str, 73 | mappings: str, 74 | message_template: str, 75 | tag: bool, 76 | undo_renamed_vars: bool, 77 | ) -> Config: 78 | 79 | global config 80 | 81 | click.echo("Saving config") 82 | 83 | config = Config(repo_path, mappings, message_template, tag, undo_renamed_vars) 84 | config.save() 85 | _commit_config() 86 | 87 | return config 88 | 89 | 90 | def config_exists(repo_path: str) -> bool: 91 | return os.path.exists(_config_path_for_repo(repo_path)) 92 | 93 | 94 | def init_config( 95 | repo_path: str, 96 | mappings: str, 97 | message_template: str, 98 | tag: bool, 99 | undo_renamed_vars: bool, 100 | ) -> bool: 101 | """ 102 | Initialize the config state 103 | 104 | If a .shulkr file exists for the current repo, it will be loaded. 105 | Otherwise, a new one will be created with the specified mappings. 106 | 107 | Args: 108 | repo_path (str): _description_ 109 | mappings (str): _description_ 110 | 111 | Returns: 112 | bool: True if a config was loaded. False if a new one was created. 113 | """ 114 | 115 | global config 116 | 117 | if config_exists(repo_path): 118 | config = _load_config(repo_path) 119 | return True 120 | else: 121 | config = _create_config( 122 | repo_path, mappings, message_template, tag, undo_renamed_vars 123 | ) 124 | return False 125 | 126 | 127 | def clear_config() -> None: 128 | """ 129 | Unload the config from memory 130 | 131 | Used in tests 132 | """ 133 | 134 | global config 135 | 136 | config = None 137 | 138 | 139 | def get_config(): 140 | return config 141 | 142 | 143 | config = None 144 | -------------------------------------------------------------------------------- /tests/command/unit/test_command.py: -------------------------------------------------------------------------------- 1 | from subprocess import CalledProcessError 2 | from unittest.mock import ANY 3 | 4 | import pytest 5 | 6 | import command 7 | from command import Command, CommandError 8 | 9 | 10 | SUBPROCESS_ANY_ARGS = { 11 | "cwd": ANY, 12 | "check": ANY, 13 | "capture_output": ANY, 14 | "text": ANY, 15 | } 16 | 17 | 18 | class GitError(CommandError): 19 | pass 20 | 21 | 22 | @pytest.fixture(autouse=True) 23 | def processes(mocker) -> None: 24 | mocker.patch("command.subprocess.run") 25 | mocker.patch("command.shutil.which", return_value=True) 26 | 27 | 28 | @pytest.fixture 29 | def git() -> Command: 30 | return Command("git", working_dir="/foo/bar", error=GitError) 31 | 32 | 33 | @pytest.fixture 34 | def silent_git() -> Command: 35 | return Command("git", working_dir="/foo/bar", capture_output=False, error=GitError) 36 | 37 | 38 | class TestGitCommand: 39 | def test_getattr_calls_subprocess_with_cwd_set_to_repo_path(self, git): 40 | git.status() 41 | 42 | subprocess_args = {**SUBPROCESS_ANY_ARGS, "cwd": "/foo/bar"} 43 | 44 | command.subprocess.run.assert_called_once_with(ANY, **subprocess_args) 45 | 46 | def test_getattr_with_no_arguments_calls_corresponding_git_subcommand(self, git): 47 | git.status() 48 | 49 | command.subprocess.run.assert_called_once_with( 50 | ["git", "status"], **SUBPROCESS_ANY_ARGS 51 | ) 52 | 53 | def test_getattr_with_one_positional_argument_calls_corresponding_git_subcommand_concatenated_with_argument( 54 | self, git 55 | ): 56 | git.status("src") 57 | 58 | command.subprocess.run.assert_called_once_with( 59 | ["git", "status", "src"], **SUBPROCESS_ANY_ARGS 60 | ) 61 | 62 | def test_getattr_with_one_nonboolean_keyword_argument_calls_corresponding_git_subcommand_concatenated_with_formatted_keyword_argument( 63 | self, git 64 | ): 65 | git.log(n=3) 66 | 67 | command.subprocess.run.assert_called_once_with( 68 | ["git", "log", "-n", "3"], **SUBPROCESS_ANY_ARGS 69 | ) 70 | 71 | def test_getattr_with_one_keyword_argument_set_to_1_sets_the_corresponding_option_to_1( 72 | self, git 73 | ): 74 | git.log(n=1) 75 | 76 | # It should not be 'git log -n' - it should be 'git log -n 1' 77 | command.subprocess.run.assert_called_once_with( 78 | ["git", "log", "-n", "1"], **SUBPROCESS_ANY_ARGS 79 | ) 80 | 81 | def test_getattr_with_one_keyword_argument_set_to_true_calls_corresponding_git_subcommand_concatenated_with_corresponding_flag( 82 | self, git 83 | ): 84 | git.log(oneline=True) 85 | 86 | command.subprocess.run.assert_called_once_with( 87 | ["git", "log", "--oneline"], **SUBPROCESS_ANY_ARGS 88 | ) 89 | 90 | def test_getattr_with_one_keyword_argument_set_to_false_calls_corresponding_git_subcommand_concatenated_without_corresponding_flag( 91 | self, git 92 | ): 93 | git.log(oneline=False) 94 | 95 | command.subprocess.run.assert_called_once_with( 96 | ["git", "log"], **SUBPROCESS_ANY_ARGS 97 | ) 98 | 99 | def test_getattr_with_one_positional_argument_and_one_keyword_argument_calls_git_with_the_keyword_argument_before_the_positional_argument( 100 | self, git 101 | ): 102 | git.log("HEAD", oneline=True) 103 | 104 | command.subprocess.run.assert_called_once_with( 105 | ["git", "log", "--oneline", "HEAD"], **SUBPROCESS_ANY_ARGS 106 | ) 107 | 108 | def test_getattr_raises_correct_error_when_subprocess_raises_an_error(self, git): 109 | command.subprocess.run.side_effect = CalledProcessError( 110 | 1, "git", "some error message" 111 | ) 112 | 113 | with pytest.raises(GitError): 114 | git.status() 115 | 116 | def test_getattr_with_capture_output_set_to_false_does_not_capture_output( 117 | self, silent_git 118 | ): 119 | silent_git.status() 120 | 121 | subprocess_args = {**SUBPROCESS_ANY_ARGS, "capture_output": False} 122 | 123 | command.subprocess.run.assert_called_once_with(ANY, **subprocess_args) 124 | -------------------------------------------------------------------------------- /src/minecraft/source.py: -------------------------------------------------------------------------------- 1 | import os 2 | import shutil 3 | import subprocess 4 | 5 | import click 6 | 7 | from gradle.project import Project 8 | from mint.repo import Repo 9 | 10 | from minecraft.version import Version 11 | 12 | 13 | DECOMPILER_MC_REMOTE_URL = "https://github.com/hube12/DecompilerMC.git" 14 | YARN_REMOTE_URL = "https://github.com/FabricMC/yarn.git" 15 | 16 | 17 | def _setup_decompiler(local_dir: str, remote_url: str) -> Repo: 18 | if os.path.exists(os.path.join(local_dir, ".git")): 19 | # Used cached yarn repo 20 | return Repo(local_dir) 21 | else: 22 | # Clone the yarn repo 23 | click.echo(f"Cloning {remote_url} into {local_dir}") 24 | return Repo.clone(remote_url, local_dir) 25 | 26 | 27 | def _generate_sources_with_yarn(version: Version, path: str) -> None: 28 | # Remove decompiler generated by previous versions of shulkr 29 | old_decompiler_path = os.path.join(path, ".yarn") 30 | if os.path.exists(old_decompiler_path): 31 | shutil.rmtree(old_decompiler_path) 32 | 33 | decompiler_path = os.path.join(path, "yarn") 34 | decompiler_repo = _setup_decompiler(decompiler_path, YARN_REMOTE_URL) 35 | 36 | click.echo(f"Updating mappings to version {version}") 37 | 38 | # Get latest versions from remote 39 | decompiler_repo.git.fetch(prune=True) 40 | 41 | decompiler_repo.git.reset("HEAD", hard=True) 42 | decompiler_repo.git.clean(force=True, d=True) 43 | 44 | # Checkout version branch 45 | decompiler_repo.git.checkout(f"origin/{version}") 46 | 47 | click.echo("Running decompiler") 48 | 49 | # Generate source code 50 | # Create gradle project 51 | decompiler_project = Project(decompiler_repo.path) 52 | 53 | # Call gradle task to generate sources 54 | decompiler_project.gradle.decompileCFR() 55 | 56 | click.echo("Moving generated sources") 57 | 58 | src_path = os.path.join(path, "src") 59 | 60 | # Remove existing top-level destination directory 61 | if os.path.exists(src_path): 62 | shutil.rmtree(src_path) 63 | 64 | # Move the generated source code to $repo_dir/src 65 | shutil.move( 66 | os.path.join( 67 | decompiler_repo.path, 68 | "src" if version >= Version.of("1.20.5") else "namedSrc", 69 | ), 70 | src_path, 71 | ) 72 | 73 | 74 | def _generate_sources_with_mojang(version: Version, path: str) -> None: 75 | """ 76 | Decompiles a version of the Minecraft client with DecompilerMC (which uses 77 | Mojang's official mappings) 78 | 79 | Args: 80 | version (Version): 81 | 82 | Raises: 83 | Exception: If DecompilerMC fails 84 | """ 85 | 86 | # Remove decompiler generated by previous versions of shulkr 87 | old_decompiler_path = os.path.join(path, ".DecompilerMC") 88 | if os.path.exists(old_decompiler_path): 89 | shutil.rmtree(old_decompiler_path) 90 | 91 | decompiler_path = os.path.join(path, "DecompilerMC") 92 | decompiler_repo = _setup_decompiler(decompiler_path, DECOMPILER_MC_REMOTE_URL) 93 | 94 | click.echo("Running decompiler") 95 | 96 | # Decompile client 97 | p = subprocess.run( 98 | ["python3", "main.py", "--mcv", str(version), "-s", "client", "-c", "-f", "-q"], 99 | stderr=subprocess.PIPE, 100 | cwd=decompiler_repo.path, 101 | ) 102 | if p.returncode != 0: 103 | raise Exception(p.stderr.decode()) 104 | 105 | click.echo("Moving generated sources") 106 | 107 | # Sources directory 108 | dest_src_dir = os.path.join(path, "src") 109 | 110 | # Remove existing top-level destination directory 111 | if os.path.exists(dest_src_dir): 112 | shutil.rmtree(dest_src_dir) 113 | 114 | # Move the generated source code to $dest_dir/src 115 | shutil.move( 116 | os.path.join(decompiler_repo.path, "src", str(version), "client"), dest_src_dir 117 | ) 118 | 119 | 120 | def generate_sources(version: Version, mappings: str, path: str) -> None: 121 | if mappings == "mojang": 122 | _generate_sources_with_mojang(version, path) 123 | 124 | elif mappings == "yarn": 125 | _generate_sources_with_yarn(version, path) 126 | 127 | else: 128 | raise ValueError(f"Invalid mapping type '{mappings}'") 129 | -------------------------------------------------------------------------------- /tests/shulkr/unit/conftest.py: -------------------------------------------------------------------------------- 1 | import os 2 | from unittest.mock import MagicMock 3 | 4 | from mint.error import GitError 5 | from mint.repo import Repo 6 | import pytest 7 | from shulkr.config import Config, get_config 8 | from shulkr.repo import get_repo 9 | 10 | 11 | def create_repo(mocker, path: str): 12 | repo = MagicMock() 13 | 14 | # Make the repo directory useful for testing shulkr.minecraft.source 15 | repo.path = os.path.abspath(path) 16 | 17 | class MockGitCommand: 18 | def add(): 19 | pass 20 | 21 | def checkout(): 22 | pass 23 | 24 | def clean(): 25 | pass 26 | 27 | def commit(): 28 | pass 29 | 30 | def describe(): 31 | pass 32 | 33 | def fetch(): 34 | pass 35 | 36 | def reset(): 37 | pass 38 | 39 | def tag(): 40 | pass 41 | 42 | def rev_parse(): 43 | pass 44 | 45 | repo.git = mocker.create_autospec(MockGitCommand()) 46 | 47 | return repo 48 | 49 | 50 | @pytest.fixture 51 | def config(mocker): 52 | """ 53 | Use a fake config 54 | 55 | The advantage of using this fixture over calling init_config is that this 56 | fixture does not use any operating system resources. 57 | """ 58 | 59 | config = Config( 60 | repo_path=os.path.abspath("foo"), 61 | mappings="mojang", 62 | message_template="{}", 63 | tag=True, 64 | undo_renamed_vars=False, 65 | ) 66 | mocker.patch("shulkr.config.config", config) 67 | 68 | return config 69 | 70 | 71 | @pytest.fixture 72 | def decompiler(mocker): 73 | """ 74 | Mock _setup_decompiler() to return a fake git repo with the current 75 | path 76 | """ 77 | 78 | def mocked_setup_decompiler(local_dir: str, _remote_url: str) -> Repo: 79 | """Create a fake decompiler subdirectory (yarn or DecompilerMC)""" 80 | 81 | # It will be located directly under the shulkr repo directory 82 | repo = get_repo() 83 | decompiler_dir = os.path.join(repo.path, local_dir) 84 | 85 | # Create a fake git repo, and set the path property 86 | return create_repo(mocker, decompiler_dir) 87 | 88 | # Tell the generate_sources functions to use our fake decompiler creator 89 | mocker.patch("minecraft.source._setup_decompiler", new=mocked_setup_decompiler) 90 | 91 | 92 | @pytest.fixture 93 | def empty_repo(mocker, decompiler): 94 | repo = create_repo(mocker, "foo") 95 | 96 | # Throw error when `git rev-parse` is called 97 | def rev_parse(*args, **kwargs): 98 | raise GitError( 99 | "git rev-parse...", 100 | stderr="fatal: ambiguous argument 'HEAD': unknown revision or path not in the working tree.", 101 | ) 102 | 103 | mocker.patch.object(repo.git, "rev_parse", side_effect=rev_parse) 104 | 105 | # Throw error when 'git describe' is called (it will only be called with 106 | # --tags) 107 | describe_error = GitError( 108 | "git describe...", stderr="fatal: No names found, cannot describe anything." 109 | ) 110 | mocker.patch.object(repo.git, "describe", side_effect=describe_error) 111 | 112 | # get_repo() will return this value 113 | mocker.patch("shulkr.repo.repo", repo) 114 | 115 | return repo 116 | 117 | 118 | @pytest.fixture 119 | def nonempty_repo(mocker, decompiler): 120 | repo = create_repo(mocker, "foo") 121 | 122 | # Add a fake commit 123 | mocker.patch.object( 124 | repo.git, 125 | "rev_parse", 126 | return_value=iter(["9e71573c6ae5a52195274871a679a23379ad1274"]), 127 | ) 128 | 129 | # Add a fake tag for that commit (it will only be called with --tags) 130 | mocker.patch.object(repo.git, "describe", return_value="abcdef") 131 | 132 | # get_repo() will return this value 133 | mocker.patch("shulkr.repo.repo", repo) 134 | 135 | return repo 136 | 137 | 138 | @pytest.fixture 139 | def yarn_mappings(config): 140 | config = get_config() 141 | 142 | prev_mappings = config.mappings 143 | config.mappings = "yarn" 144 | 145 | yield 146 | 147 | config.mappings = prev_mappings 148 | 149 | 150 | @pytest.fixture 151 | def mojang_mappings(config): 152 | config = get_config() 153 | 154 | prev_mappings = config.mappings 155 | config.mappings = "mojang" 156 | 157 | yield 158 | 159 | config.mappings = prev_mappings 160 | 161 | 162 | @pytest.fixture 163 | def root_dir(): 164 | script_dir = os.path.dirname(__file__) 165 | return os.path.realpath(os.path.join(script_dir, "..", "..")) 166 | -------------------------------------------------------------------------------- /tests/shulkr/unit/test_app.py: -------------------------------------------------------------------------------- 1 | from minecraft.version import Version 2 | import pytest 3 | 4 | from shulkr import app 5 | 6 | 7 | @pytest.fixture 8 | def versions(): 9 | return [Version(id="1.18", index=0), Version(id="1.19", index=1)] 10 | 11 | 12 | @pytest.fixture(autouse=True) 13 | def mock_all(mocker, versions): 14 | mocker.patch("shulkr.app.click") 15 | mocker.patch("shulkr.app.load_manifest") 16 | mocker.patch("shulkr.app.os") 17 | mocker.patch("shulkr.app.init_repo") 18 | mocker.patch("shulkr.app.is_compatible") 19 | mocker.patch("shulkr.app.init_config") 20 | mocker.patch("shulkr.app.ensure_gitignore_exists") 21 | mocker.patch("shulkr.app.Version.patterns", return_value=versions) 22 | mocker.patch("shulkr.app.get_latest_generated_version") 23 | mocker.patch("shulkr.app.create_version") 24 | 25 | 26 | def test_run_loads_version_manifest(): 27 | app.run( 28 | versions=[], 29 | mappings="mappings", 30 | repo_path="path/to/repo", 31 | message_template="message", 32 | tags=True, 33 | undo_renamed_vars=True, 34 | ) 35 | 36 | app.load_manifest.assert_called_once_with() 37 | 38 | 39 | def test_run_calls_init_repo(mocker): 40 | app.os.path.join.return_value = "full/path/to/repo" 41 | 42 | app.run( 43 | versions=[], 44 | mappings="mappings", 45 | repo_path="path/to/repo", 46 | message_template="message", 47 | tags=True, 48 | undo_renamed_vars=True, 49 | ) 50 | 51 | app.init_repo.assert_called_once_with("full/path/to/repo") 52 | 53 | 54 | def test_run_with_unsupported_repo_exits_with_error(): 55 | app.is_compatible.return_value = False 56 | 57 | with pytest.raises(SystemExit, match="4"): 58 | app.run( 59 | versions=[], 60 | mappings="mappings", 61 | repo_path="path/to/repo", 62 | message_template="message", 63 | tags=True, 64 | undo_renamed_vars=True, 65 | ) 66 | 67 | 68 | def test_run_calls_init_config_when_init_repo_returns_false(): 69 | app.init_repo.return_value = False 70 | app.os.path.join.return_value = "full/path/to/repo" 71 | 72 | app.run( 73 | versions=[], 74 | mappings="mappings", 75 | repo_path="path/to/repo", 76 | message_template="message", 77 | tags=True, 78 | undo_renamed_vars=True, 79 | ) 80 | 81 | app.init_config.assert_called_once_with( 82 | "full/path/to/repo", "mappings", "message", True, True 83 | ) 84 | 85 | 86 | def test_run_calls_init_config_when_init_repo_returns_true(): 87 | app.init_repo.return_value = True 88 | app.os.path.join.return_value = "full/path/to/repo" 89 | 90 | app.run( 91 | versions=[], 92 | mappings="mappings", 93 | repo_path="path/to/repo", 94 | message_template="message", 95 | tags=True, 96 | undo_renamed_vars=True, 97 | ) 98 | 99 | app.init_config.assert_called_once_with( 100 | "full/path/to/repo", "mappings", "message", True, True 101 | ) 102 | 103 | 104 | def test_run_calls_ensure_gitignore_exists(): 105 | app.run( 106 | versions=[], 107 | mappings="mappings", 108 | repo_path="path/to/repo", 109 | message_template="message", 110 | tags=True, 111 | undo_renamed_vars=True, 112 | ) 113 | 114 | app.ensure_gitignore_exists.assert_called_once_with() 115 | 116 | 117 | def test_run_with_version_older_than_latest_version_in_repo_exits_with_error(): 118 | app.get_latest_generated_version.return_value = Version(id="1.18", index=1) 119 | 120 | with pytest.raises(SystemExit, match="3"): 121 | app.run( 122 | versions=[Version(id="1.17", index=0)], 123 | mappings="mappings", 124 | repo_path="path/to/repo", 125 | message_template="message", 126 | tags=True, 127 | undo_renamed_vars=True, 128 | ) 129 | 130 | 131 | def test_run_with_multiple_versions_calls_create_version_for_each_version( 132 | mocker, versions 133 | ): 134 | app.run( 135 | versions=[], 136 | mappings="mappings", 137 | repo_path="path/to/repo", 138 | message_template="message", 139 | tags=True, 140 | undo_renamed_vars=True, 141 | ) 142 | 143 | app.create_version.assert_has_calls([mocker.call(version) for version in versions]) 144 | 145 | 146 | def test_run_without_any_versions_exits(): 147 | app.Version.patterns.return_value = [] 148 | 149 | with pytest.raises(SystemExit, match="0"): 150 | app.run( 151 | versions=[], 152 | mappings="mappings", 153 | repo_path="path/to/repo", 154 | message_template="message", 155 | tags=True, 156 | undo_renamed_vars=True, 157 | ) 158 | -------------------------------------------------------------------------------- /tests/minecraft/unit/test_version.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from minecraft.version import Version 4 | 5 | 6 | class TestVersion: 7 | def test_next_of_snapshot_is_the_release(self, versions): 8 | assert versions.snapshot.next == versions.release 9 | 10 | def test_next_of_release_is_none(self, versions): 11 | assert versions.release.next is None 12 | 13 | def test_release_does_not_equal_snapshot(self, versions): 14 | assert versions.release != versions.snapshot 15 | 16 | def test_snapshot_is_less_than_release(self, versions): 17 | assert versions.snapshot < versions.release 18 | 19 | def test_release_is_greater_than_snapshot(self, versions): 20 | assert versions.release > versions.snapshot 21 | 22 | def test_snapshot_to_release_returns_list_containing_snapshot_and_release( 23 | self, versions 24 | ): 25 | assert versions.snapshot.to(versions.release) == [ 26 | versions.snapshot, 27 | versions.release, 28 | ] 29 | 30 | def test_snapshot_to_snapshot_returns_list_containing_snapshot(self, versions): 31 | assert versions.snapshot.to(versions.snapshot) == [versions.snapshot] 32 | 33 | def test_snapshot_to_none_returns_list_containing_snapshot_and_release( 34 | self, versions 35 | ): 36 | assert versions.snapshot.to(None) == [versions.snapshot, versions.release] 37 | 38 | def test_snapshot_to_release_excluding_snapshots_returns_list_containing_release( 39 | self, versions 40 | ): 41 | assert versions.snapshot.to(versions.release, snapshots=False) == [ 42 | versions.release 43 | ] 44 | 45 | def test_release_to_snapshot_raises_version_error(self, versions): 46 | with pytest.raises(ValueError): 47 | versions.release.to(versions.snapshot) 48 | 49 | def test_pattern_with_no_range_operator_returns_list_containing_version( 50 | self, versions 51 | ): 52 | assert Version.pattern("1.20.5") == [versions.release] 53 | 54 | def test_pattern_with_snapshot_two_dots_returns_list_containing_release( 55 | self, versions 56 | ): 57 | assert Version.pattern("abcdef..") == [versions.release] 58 | 59 | def test_pattern_with_snapshot_three_dots_returns_list_containing_snapshot_and_release( 60 | self, versions 61 | ): 62 | assert Version.pattern("abcdef...") == [versions.snapshot, versions.release] 63 | 64 | def test_pattern_with_release_two_dots_returns_list_containing_release( 65 | self, versions 66 | ): 67 | assert Version.pattern("1.20.5..") == [versions.release] 68 | 69 | def test_pattern_with_release_two_dots_release_returns_list_containing_release( 70 | self, versions 71 | ): 72 | assert Version.pattern("1.20.5..1.20.5") == [versions.release] 73 | 74 | def test_pattern_with_snapshot_three_dots_release_returns_list_containing_snapshot_and_release( 75 | self, versions 76 | ): 77 | assert Version.pattern("abcdef...1.20.5") == [ 78 | versions.snapshot, 79 | versions.release, 80 | ] 81 | 82 | def test_pattern_with_three_dots_on_empty_repo_throws_value_error(self, versions): 83 | with pytest.raises( 84 | ValueError, match="No commits from which to derive current version" 85 | ): 86 | Version.pattern("...", latest_in_repo=None) == [ 87 | versions.snapshot, 88 | versions.release, 89 | ] 90 | 91 | def test_pattern_with_two_dots_on_repo_with_snapshot_returns_list_containing_release( 92 | self, versions 93 | ): 94 | assert Version.pattern("..", latest_in_repo=versions.snapshot) == [ 95 | versions.release 96 | ] 97 | 98 | def test_pattern_with_three_dots_on_repo_with_snapshot_returns_list_containing_release( 99 | self, versions 100 | ): 101 | assert Version.pattern("...", latest_in_repo=versions.snapshot) == [ 102 | versions.release 103 | ] 104 | 105 | def test_patterns_with_empty_list_returns_empty_list(self, versions): 106 | assert Version.patterns([]) == [] 107 | 108 | def test_patterns_with_one_positive_id_returns_corresponding_version( 109 | self, versions 110 | ): 111 | assert Version.patterns(["1.20.5"]) == [versions.release] 112 | 113 | def test_patterns_with_two_positive_identical_ids_returns_one_version( 114 | self, versions 115 | ): 116 | assert Version.patterns(["1.20.5", "1.20.5"]) == [versions.release] 117 | 118 | def test_patterns_with_one_positive_id_and_the_same_negative_id_returns_empty_list( 119 | self, versions 120 | ): 121 | assert Version.patterns(["1.20.5", "-1.20.5"]) == [] 122 | 123 | def test_patterns_with_one_positive_id_and_the_same_negative_id_and_the_same_positive_id_returns_one_version( 124 | self, versions 125 | ): 126 | assert Version.patterns(["1.20.5", "-1.20.5", "1.20.5"]) == [versions.release] 127 | -------------------------------------------------------------------------------- /tests/shulkr/unit/test_config.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | from shulkr.config import Config, init_config 4 | 5 | 6 | class TestConfig: 7 | def test_save_opens_config_file_for_writing(self, mocker): 8 | # 1. Create a new configuration 9 | config = Config( 10 | repo_path="foo", 11 | mappings="mojang", 12 | message_template="{}", 13 | tag=True, 14 | undo_renamed_vars=False, 15 | ) 16 | 17 | # 2. Mock open() 18 | open_ = mocker.patch("shulkr.config.open") 19 | 20 | # 3. Try to save the configuration to the disk 21 | config.save() 22 | 23 | # 4. Make sure open() was called currectly 24 | config_path = os.path.join("foo", ".shulkr") 25 | open_.assert_called_once_with(config_path, "w+") 26 | 27 | 28 | def test_init_config_creates_new_configuration_with_provided_arguments_if_config_file_is_not_found( 29 | mocker, empty_repo 30 | ): 31 | # 1. Spy on Config constructor 32 | Config_ = mocker.patch("shulkr.config.Config") 33 | 34 | # 2. Patch os.path.exists to return False 35 | mocker.patch("shulkr.config.os.path.exists", return_value=False) 36 | 37 | # 3. Call init_config 38 | init_config( 39 | repo_path="foo", 40 | mappings="mojang", 41 | message_template="{}", 42 | tag=True, 43 | undo_renamed_vars=False, 44 | ) 45 | 46 | # 4. The Config constructor should have been called with the specified 47 | # path and mappings 48 | Config_.assert_called_once_with("foo", "mojang", "{}", True, False) 49 | 50 | 51 | def test_init_config_commits_config_when_creating_new_one(mocker, empty_repo): 52 | # 1. Stub out Config class 53 | mocker.patch("shulkr.config.Config") 54 | 55 | # 2. Mock 'git commit' 56 | mocker.patch.object(empty_repo.git, "commit") 57 | 58 | # 3. No existing config should be found 59 | mocker.patch("shulkr.config.os.path.exists", return_value=False) 60 | 61 | # 4. Call init_config 62 | init_config( 63 | repo_path="foo", 64 | mappings="mojang", 65 | message_template="{}", 66 | tag=True, 67 | undo_renamed_vars=False, 68 | ) 69 | 70 | # 5. "git commit --message 'add .shulkr' .shulkr" 71 | empty_repo.git.add.assert_called_once_with(".shulkr") 72 | empty_repo.git.commit.assert_called_once_with(message="add .shulkr") 73 | 74 | 75 | def test_init_config_loads_existing_config_if_config_file_is_found(mocker): 76 | # 1. Stub out the Config constructor 77 | Config_ = mocker.patch("shulkr.config.Config") 78 | 79 | # 2. Add a fake config 80 | # 2a. Patch os.path.exists to return True 81 | mocker.patch("shulkr.config.os.path.exists", return_value=True) 82 | 83 | # 2b. Stub out open() 84 | mocker.patch("shulkr.config.open") 85 | 86 | # 2c. Patch toml.load to return a dummy config file 87 | raw_config = { 88 | "mappings": "yarn", 89 | "message": "Minecraft {}", 90 | "tag": False, 91 | "experimental": {"undo_renamed_vars": True}, 92 | } 93 | mocker.patch("shulkr.config.toml.load", return_value=raw_config) 94 | 95 | # 3. Call init_config 96 | init_config( 97 | repo_path="foo", 98 | mappings="mojang", 99 | message_template="{}", 100 | tag=True, 101 | undo_renamed_vars=False, 102 | ) 103 | 104 | # 4. The Config constructor should have been called with the path and 105 | # mappings from the existing config 106 | Config_.assert_called_once_with( 107 | repo_path="foo", 108 | mappings="yarn", 109 | message_template="Minecraft {}", 110 | tag=False, 111 | undo_renamed_vars=True, 112 | ) 113 | 114 | 115 | def test_init_config_returns_true_if_config_file_is_found(mocker): 116 | # 1. Mock dependencies 117 | mocker.patch("shulkr.config.Config") 118 | mocker.patch("shulkr.config.os.path.exists", return_value=True) 119 | mocker.patch("shulkr.config.open") 120 | raw_config = { 121 | "mappings": "yarn", 122 | "message": "Minecraft {}", 123 | "tag": False, 124 | "experimental": {"undo_renamed_vars": True}, 125 | } 126 | mocker.patch("shulkr.config.toml.load", return_value=raw_config) 127 | 128 | # 2. Call init_config 129 | result = init_config( 130 | repo_path="foo", 131 | mappings="mojang", 132 | message_template="{}", 133 | tag=True, 134 | undo_renamed_vars=False, 135 | ) 136 | 137 | # 3. The result should be True 138 | assert result 139 | 140 | 141 | def test_init_config_returns_false_if_config_file_is_not_found(mocker, empty_repo): 142 | # 1. Mock dependencies 143 | mocker.patch("shulkr.config.Config") 144 | mocker.patch("shulkr.config.os.path.exists", return_value=False) 145 | 146 | # 2. Call init_config 147 | result = init_config( 148 | repo_path="foo", 149 | mappings="mojang", 150 | message_template="{}", 151 | tag=True, 152 | undo_renamed_vars=False, 153 | ) 154 | 155 | # 3. The result should be False 156 | assert not result 157 | -------------------------------------------------------------------------------- /tests/shulkr/unit/test_version.py: -------------------------------------------------------------------------------- 1 | from minecraft.version import Version 2 | 3 | import shulkr 4 | from shulkr.config import Config 5 | from shulkr.version import create_version, get_latest_generated_version 6 | 7 | 8 | def test_create_version_calls_generate_sources_with_mappings_from_config_and_correct_version( 9 | mocker, config: Config, empty_repo 10 | ): 11 | # Set mappings in config 12 | config.mappings = "foo" 13 | 14 | # Mock 15 | mocker.patch("shulkr.version.click") 16 | mocker.patch("shulkr.version.generate_sources") 17 | 18 | # Call create_version 19 | version = Version("1.18.1", 0) 20 | create_version(version) 21 | 22 | # generate_sources() should have been called with the correct arguments 23 | shulkr.version.generate_sources.assert_called_once_with( 24 | version, "foo", empty_repo.path 25 | ) 26 | 27 | 28 | def test_create_version_with_undo_renamed_vars_on_repo_with_no_commits_does_not_call_undo_renames( 29 | mocker, config, empty_repo 30 | ): 31 | # Mock 32 | # mocker.patch('shulkr.open', create=True) 33 | mocker.patch("shulkr.version.click") 34 | mocker.patch("shulkr.version.generate_sources") 35 | mocker.patch("shulkr.version.undo_renames") 36 | 37 | # Call create_version 38 | config.undo_renamed_vars = True 39 | version = Version("1.18.1", 0) 40 | create_version(version) 41 | 42 | # Assert that undo_renames was not called 43 | shulkr.version.undo_renames.assert_not_called() 44 | 45 | 46 | def test_create_version_with_undo_renamed_vars_on_repo_with_one_commit_calls_undo_renames( 47 | mocker, config, nonempty_repo 48 | ): 49 | # Mock 50 | # mocker.patch('shulkr.open', create=True) 51 | mocker.patch("shulkr.version.click") 52 | mocker.patch("shulkr.version.generate_sources") 53 | mocker.patch("shulkr.version.undo_renames") 54 | 55 | # Call create_version 56 | config.undo_renamed_vars = True 57 | version = Version("1.18.1", 0) 58 | create_version(version) 59 | 60 | # Assert that undo_renames was not called 61 | shulkr.version.undo_renames.assert_called_once() 62 | 63 | 64 | def test_create_version_with_yarn_mappings_stages_the_src_directory( 65 | mocker, config, empty_repo, yarn_mappings 66 | ): 67 | mocker.patch("shulkr.version.click") 68 | mocker.patch("shulkr.version.generate_sources") 69 | 70 | # Call _commit_version 71 | version = Version("1.18.1", 0) 72 | create_version(version) 73 | 74 | # src needs to have been staged 75 | empty_repo.git.add.assert_called_once_with("src") 76 | 77 | 78 | def test_create_version_with_mojang_mappings_stages_the_src_directory( 79 | mocker, config, empty_repo, mojang_mappings 80 | ): 81 | mocker.patch("shulkr.version.click") 82 | mocker.patch("shulkr.version.generate_sources") 83 | 84 | # Call _commit_version 85 | version = Version("1.18.1", 0) 86 | create_version(version) 87 | 88 | # client and server need to have been staged 89 | empty_repo.git.add.assert_called_once_with("src") 90 | 91 | 92 | def test_create_version_creates_a_commit(mocker, config, empty_repo): 93 | mocker.patch("shulkr.version.click") 94 | mocker.patch("shulkr.version.generate_sources") 95 | 96 | # Call _commit_version 97 | version = Version("1.18.1", 0) 98 | create_version(version) 99 | 100 | # commit must have been called 101 | empty_repo.git.commit.assert_called_once() 102 | 103 | 104 | def test_create_version_replaces_all_brackets_with_the_version( 105 | mocker, config, empty_repo 106 | ): 107 | mocker.patch("shulkr.version.click") 108 | mocker.patch("shulkr.version.generate_sources") 109 | 110 | # Call _commit_version 111 | config.message_template = "{} {}" 112 | version = Version("1.18.1", 0) 113 | create_version(version) 114 | 115 | # commit must have been called 116 | expected_message = config.message_template.replace("{}", str(version)) 117 | empty_repo.git.commit.assert_called_once_with(message=expected_message) 118 | 119 | 120 | def test_create_version_with_existing_commits_and_undo_renamed_vars_adds_note_to_commit_message( 121 | mocker, config, nonempty_repo 122 | ): 123 | mocker.patch("shulkr.version.click") 124 | mocker.patch("shulkr.version.generate_sources") 125 | 126 | # Call _commit_version 127 | config.undo_renamed_vars = True 128 | version = Version("1.18.1", 0) 129 | create_version(version) 130 | 131 | # commit must have been called 132 | expected_message = f"{version}\n\nRenamed variables reverted" 133 | nonempty_repo.git.commit.assert_called_once_with(message=expected_message) 134 | 135 | 136 | def test_create_version_with_tag_calls_git_tag(mocker, config, nonempty_repo): 137 | mocker.patch("shulkr.version.click") 138 | mocker.patch("shulkr.version.generate_sources") 139 | 140 | version = Version("1.18.1", 0) 141 | create_version(version) 142 | 143 | nonempty_repo.git.tag.assert_called_once_with(version) 144 | 145 | 146 | def test_get_latest_generated_version_with_repo_with_one_version_returns_version( 147 | mocker, nonempty_repo 148 | ): 149 | mocker.patch("shulkr.version.Version.of") 150 | 151 | # nonempty_repo contains one commit (the snapshot) 152 | get_latest_generated_version() 153 | 154 | shulkr.version.Version.of.assert_called_once_with("abcdef") 155 | -------------------------------------------------------------------------------- /docs/changelog.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | All notable changes to this project will be documented in this file. 3 | 4 | The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), 5 | and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). 6 | 7 | ## [Unreleased] 8 | 9 | ## [0.7.2] - 2024-05-04 10 | ### Fixed 11 | - *namedSrc not found* error when decompiling Minecraft 1.20.5 with Yarn mappings. 12 | 13 | ### Security 14 | - Updated `cryptography` from 40.0.2 to 42.0.4 15 | - Updated `requests` from 2.28.1 to 2.31.0 16 | - Updated `certifi` from 2023.5.7 to 2023.7.22 17 | - Updated `urllib3` from 2.0.5 to 2.0.7 18 | - Updated `gitpython` from 3.1.30 to 3.1.41 19 | - Updated `idna` from 3.4 to 3.7 20 | 21 | ## [0.7.1] - 2023-04-08 22 | ### Fixed 23 | - Removed leading dot from `.yarn` and `.DecompilerMC` paths for improved compatibility. 24 | 25 | ## [0.7.0] - 2022-09-08 26 | ### Changed 27 | - Git commands are no longer run in a shell. For portability, the `git` binary is called directly. 28 | 29 | ### Fixed 30 | - Git commands not being tokenized correctly on Windows ([#18](https://github.com/clabe45/shulkr/issues/18)). 31 | - Missing dependency click. 32 | - Missing dependency colorama on Windows. 33 | 34 | ## [0.6.1] - 2022-07-09 35 | ### Fixed 36 | - Import error when invoking `shulkr`. 37 | 38 | ## [0.6.0] - 2022-07-09 39 | ### Changed 40 | - Improve terminal output. 41 | - Now exits with a status code of 0 (instead of 3) when no versions are entered. 42 | 43 | ### Fixed 44 | - Versions older than the latest version in the repo can no longer be generated. 45 | 46 | ## [0.5.0] - 2022-06-26 47 | ### Added 48 | - `.shulkr` file for repository settings. 49 | 50 | ### Changed 51 | - Server sources are no longer generated seperately when using Mojang's mappings (the client includes them). 52 | 53 | ## [0.4.4] - 2022-05-31 54 | ### Fixed 55 | - Omitting the first version in a range pattern starts after the newest version in the repo instead of the oldest one. 56 | 57 | ## [0.4.3] - 2022-05-31 58 | ### Fixed 59 | - Omitting the first version in a range pattern now works correctly after checking existing tags out with git. 60 | 61 | ## [0.4.2] - 2022-04-13 62 | ### Fixed 63 | - `..X` now starts with the next release (not including snapshots). 64 | - `..X` and `...X` raising an exception if the repo is up-to-date. 65 | 66 | ## [0.4.1] - 2022-04-13 67 | ### Fixed 68 | - Next version not being detected correctly. 69 | - `NoSuchPathError` when creating a repo without a mapping specified. 70 | - `GitCommandError` when using a brand-new repo without a mapping specified. 71 | 72 | ## [0.4.0] - 2022-04-12 73 | ### Added 74 | - Support for [Yarn](https://github.com/FabricMC/yarn) mappings. 75 | - Can now be invoked in different repos at the same time. 76 | 77 | ### Changed 78 | - Omitting the first version in a range pattern defaults to the version after the latest commit. 79 | 80 | ## [0.3.3] - 2021-01-03 81 | ### Fixed 82 | - Deleted files not being committed 83 | - `--undo-renamed-vars` causing error in commit step 84 | 85 | ## [0.3.2] - 2021-12-31 86 | ### Fixed 87 | - Not all files being added to commits 88 | - `File exists` error when decompiling the second version 89 | - `--undo-renamed-vars` causing error 90 | 91 | ## [0.3.1] - 2021-12-30 92 | ### Changed 93 | - `client` and `server` are no longer deleted before each version 94 | - Reword some argument descriptions in the help page 95 | 96 | ## [0.3.0] - 2021-12-30 97 | ### Added 98 | - Each commit is now tagged with its Minecraft version 99 | 100 | ### Changed 101 | - Restructure source roots for easier project organization in IDEs 102 | - `src/client` → `client/src` 103 | - `src/server` → `server/src` 104 | 105 | ### Fixed 106 | - Issue with commit message substition 107 | 108 | ## [0.2.0] - 2021-12-16 109 | ### Added 110 | - Negative version patterns 111 | 112 | ## [0.1.1] - 2021-12-16 113 | ### Fixed 114 | - `No such file or directory: main.py` error 115 | 116 | ## [0.1.0] - 2021-12-16 117 | ### Added 118 | - Decompilation with [DecompilerMC] 119 | - Git integration 120 | - Each version committed to local repo 121 | - Range operators (`..` and `...`) 122 | 123 | [Unreleased]: https://github.com/clabe45/shulkr/compare/v0.7.2...HEAD 124 | [0.7.2]: https://github.com/clabe45/shulkr/compare/v0.7.1...v0.7.2 125 | [0.7.1]: https://github.com/clabe45/shulkr/compare/v0.7.0...v0.7.1 126 | [0.7.0]: https://github.com/clabe45/shulkr/compare/v0.6.1...v0.7.0 127 | [0.6.1]: https://github.com/clabe45/shulkr/compare/v0.6.0...v0.6.1 128 | [0.6.0]: https://github.com/clabe45/shulkr/compare/v0.5.0...v0.6.0 129 | [0.5.0]: https://github.com/clabe45/shulkr/compare/v0.4.4...v0.5.0 130 | [0.4.4]: https://github.com/clabe45/shulkr/compare/v0.4.3...v0.4.4 131 | [0.4.3]: https://github.com/clabe45/shulkr/compare/v0.4.2...v0.4.3 132 | [0.4.2]: https://github.com/clabe45/shulkr/compare/v0.4.1...v0.4.2 133 | [0.4.1]: https://github.com/clabe45/shulkr/compare/v0.4.0...v0.4.1 134 | [0.4.0]: https://github.com/clabe45/shulkr/compare/v0.3.3...v0.4.0 135 | [0.3.3]: https://github.com/clabe45/shulkr/compare/v0.3.2...v0.3.3 136 | [0.3.2]: https://github.com/clabe45/shulkr/compare/v0.3.1...v0.3.2 137 | [0.3.1]: https://github.com/clabe45/shulkr/compare/v0.3.0...v0.3.1 138 | [0.3.0]: https://github.com/clabe45/shulkr/compare/v0.2.0...v0.3.0 139 | [0.2.0]: https://github.com/clabe45/shulkr/compare/v0.1.1...v0.2.0 140 | [0.1.1]: https://github.com/clabe45/shulkr/compare/v0.1.0...v0.1.1 141 | [0.1.0]: https://github.com/clabe45/shulkr/releases/tag/v0.1.0 142 | 143 | [DecompilerMC]: https://github.com/hube12/DecompilerMC 144 | -------------------------------------------------------------------------------- /docs/contributing.md: -------------------------------------------------------------------------------- 1 | # Contributing 2 | 3 | Pull requests are welcome. For major changes, please open an issue first to 4 | discuss what you would like to change. 5 | 6 | ## Project Implementation 7 | 8 | At a high-level, shulkr does the following for each version of Minecraft 9 | resolved from the supplied version patterns: 10 | 1. Generate the source code using [DecompilerMC] or [yarn] 11 | 2. Commit the version to git 12 | 3. Optionally, tag the version 13 | 14 | ## Getting Started 15 | 16 | ### Prerequisites 17 | 18 | - [Python 3.9] 19 | - [Pipenv] 20 | - [Git] 21 | - [Java 17+] 22 | - [Gradle] 23 | 24 | ### Installation 25 | 26 | 1. [Fork] the repo 27 | 1. Clone your fork: 28 | ```sh 29 | git clone https://github.com//shulkr.git 30 | cd shulkr 31 | ``` 32 | 1. Install dependencies with Pipenv 33 | ```sh 34 | pipenv install --dev 35 | ``` 36 | 1. Install internal packages: 37 | ```sh 38 | pipenv run setup 39 | ``` 40 | 1. Run shulkr: 41 | ```sh 42 | pipenv run start 43 | ``` 44 | 45 | ## Testing 46 | 47 | Please make sure to update tests as appropriate: 48 | - **Unit tests** should mock all dependencies and test the code in isolation. 49 | - **Smoke tests** should test the most important functional requirements in a 50 | real environment. They should not mock any dependencies and should be as fast 51 | as possible. 52 | - **Functional tests** should test all functional requirements in a real 53 | environment. They should not mock any dependencies. 54 | 55 | ## Commit Message Guidelines 56 | 57 | > Adopted from [Angular's commit message guidelines] 58 | 59 | We have very precise rules over how our git commit messages can be formatted. 60 | This leads to **more readable messages** that are easy to follow when looking 61 | through the **project history**. 62 | 63 | ### Commit Message Format 64 | 65 | Each commit message consists of a **header**, a **body** and a **footer**. The 66 | header has a special format that includes a **type**, a **scope** and a 67 | **subject**: 68 | 69 | ``` 70 | (): 71 | 72 | 73 | 74 |