diff --git a/backend/pyproject.toml b/backend/pyproject.toml index 821458d66..50f9d1068 100644 --- a/backend/pyproject.toml +++ b/backend/pyproject.toml @@ -38,6 +38,7 @@ dependencies = [ "packaging>=24.2", "pathspec>=0.10.1", "pluggy>=1.0.0", + "tomlkit>=0.11.1", "tomli>=1.2.2; python_version < '3.11'", "trove-classifiers", ] diff --git a/backend/src/hatchling/cli/version/__init__.py b/backend/src/hatchling/cli/version/__init__.py index 42aa0e29b..65d4ed897 100644 --- a/backend/src/hatchling/cli/version/__init__.py +++ b/backend/src/hatchling/cli/version/__init__.py @@ -7,6 +7,7 @@ def version_impl( *, called_by_app: bool, # noqa: ARG001 + force: bool, desired_version: str, ) -> None: import os @@ -22,11 +23,30 @@ def version_impl( metadata = ProjectMetadata(root, plugin_manager) if "version" in metadata.config.get("project", {}): + import tomlkit + + project_file_path = os.path.join(root, "pyproject.toml") + + with open(project_file_path, encoding="utf-8") as fr: + raw_config = tomlkit.parse(fr.read()) + version = raw_config["project"]["version"] + if desired_version: - app.abort("Cannot set version when it is statically defined by the `project.version` field") + from hatchling.version.scheme.standard import StandardScheme + + scheme_config = {"validate-bump": False} if force else {} + scheme = StandardScheme(root, scheme_config) + updated_version = scheme.update(desired_version, version, {}) + raw_config["project"]["version"] = updated_version + + with open(project_file_path, "w", encoding="utf-8") as fw: + fw.write(tomlkit.dumps(raw_config)) + + app.display_info(f"Old: {version}") + app.display_info(f"New: {updated_version}") else: - app.display(metadata.core.version) - return + app.display(version) + return source = metadata.hatch.version.source @@ -48,4 +68,5 @@ def version_command(subparsers: argparse._SubParsersAction, defaults: Any) -> No parser = subparsers.add_parser("version") parser.add_argument("desired_version", default="", nargs="?", **defaults) parser.add_argument("--app", dest="called_by_app", action="store_true", help=argparse.SUPPRESS) + parser.add_argument("--force", action="store_true", help="Allow an explicit downgrading version to be given") parser.set_defaults(func=version_impl) diff --git a/src/hatch/cli/version/__init__.py b/src/hatch/cli/version/__init__.py index 0c329bc32..f01f298a3 100644 --- a/src/hatch/cli/version/__init__.py +++ b/src/hatch/cli/version/__init__.py @@ -26,11 +26,28 @@ def version(app: Application, *, desired_version: str | None, force: bool): app.abort(f"Project {app.project.chosen_name} (not a project)") if "version" in app.project.metadata.config.get("project", {}): + import tomlkit + + project_file_path = app.project.location / "pyproject.toml" + + raw_config = tomlkit.parse(project_file_path.read_text("utf-8")) + version = raw_config["project"]["version"] + if desired_version: - app.abort("Cannot set version when it is statically defined by the `project.version` field") + from hatchling.version.scheme.standard import StandardScheme + + scheme_config = {"validate-bump": False} if force else {} + scheme = StandardScheme(str(app.project.location), scheme_config) + updated_version = scheme.update(desired_version, version, {}) + raw_config["project"]["version"] = updated_version + + project_file_path.write_text(tomlkit.dumps(raw_config), "utf-8") + + app.display_info(f"Old: {version}") + app.display_info(f"New: {updated_version}") else: - app.display(app.project.metadata.config["project"]["version"]) - return + app.display(version) + return from hatch.config.constants import VersionEnvVars from hatch.project.constants import BUILD_BACKEND diff --git a/tests/cli/version/test_version.py b/tests/cli/version/test_version.py index f2159138a..eaff5a53a 100644 --- a/tests/cli/version/test_version.py +++ b/tests/cli/version/test_version.py @@ -348,6 +348,52 @@ def test_show_static(hatch, temp_dir): def test_set_static(hatch, helpers, temp_dir): project_name = "My.App" + test_comment = "# This is a test comment to assure version bumps do not erase comments." + + with temp_dir.as_cwd(): + hatch("new", project_name) + + path = temp_dir / "my-app" + pyproject = path / "pyproject.toml" + data_path = temp_dir / "data" + data_path.mkdir() + + # To test if formatting and comments are preserved after static set + # Regular Project.save_config does not preserve it + with open(pyproject, "r+", encoding="utf-8") as f: + lines = list(f) + for i, line in enumerate(lines): + if line.startswith("dynamic"): + lines[i] = 'version = "1.2.3"\n' + lines.append(test_comment) + f.seek(0) + f.writelines(lines) + + with path.as_cwd(env_vars={ConfigEnvVars.DATA: str(data_path)}): + result = hatch("version", "minor,rc") + + assert result.exit_code == 0, result.output + assert result.output == helpers.dedent( + """ + Old: 1.2.3 + New: 1.3.0rc0 + """ + ) + + project = Project(path) + assert project.raw_config["project"]["version"] == "1.3.0rc0", "should update static version" + + with open(pyproject, encoding="utf-8") as fr: + for line in fr: + if line.startswith("version"): + version_line = line + last_line = line + assert version_line == 'version = "1.3.0rc0"\n', "should preserve pyproject formatting" + assert last_line == test_comment, "should preserve pyproject comments" + + +def test_set_static_downgrade(hatch, helpers, temp_dir): + project_name = "My.App" with temp_dir.as_cwd(): hatch("new", project_name) @@ -362,16 +408,28 @@ def test_set_static(hatch, helpers, temp_dir): config["project"]["dynamic"].remove("version") project.save_config(config) + # This one fails, because it's a downgrade without --force with path.as_cwd(env_vars={ConfigEnvVars.DATA: str(data_path)}): - result = hatch("version", "minor,rc") + result = hatch("version", "1.2.0", catch_exceptions=True) assert result.exit_code == 1, result.output + assert str(result.exception) == "Version `1.2.0` is not higher than the original version `1.2.3`" + + # Try again, this time with --force + with path.as_cwd(env_vars={ConfigEnvVars.DATA: str(data_path)}): + result = hatch("version", "--force", "1.2.0") + + assert result.exit_code == 0, result.output assert result.output == helpers.dedent( """ - Cannot set version when it is statically defined by the `project.version` field + Old: 1.2.3 + New: 1.2.0 """ ) + project = Project(path) + assert project.raw_config["project"]["version"] == "1.2.0", "should force update static version" + @pytest.mark.requires_internet @pytest.mark.usefixtures("mock_backend_process")