Skip to content

Commit

Permalink
fix: handle missing configuration (#644)
Browse files Browse the repository at this point in the history
  • Loading branch information
bernardcooke53 committed Jul 18, 2023
1 parent 325d5e0 commit f15753c
Show file tree
Hide file tree
Showing 7 changed files with 401 additions and 43 deletions.
2 changes: 1 addition & 1 deletion docs/configuration.rst
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ Configuration
Configuration is read from a file which can be specified using the
:ref:`--config <cmd-main-option-config>` option to :ref:`cmd-main`. Python Semantic
Release currently supports either TOML- or JSON-formatted configuration, and will
attempt to detect and parse the configuration based on the file extension.
attempt to detect the configuration format and parse it.

When using a JSON-format configuration file, Python Semantic Release looks for its
settings beneath a top-level ``semantic_release`` key; when using a TOML-format
Expand Down
6 changes: 3 additions & 3 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ classifiers = [
readme = "README.rst"
authors = [{ name = "Rolf Erik Lekang", email = "me@rolflekang.com" }]
dependencies = [
"click>=7,<9",
"click>=8,<9",
"gitpython>=3.0.8,<4",
"requests>=2.25,<3",
"jinja2>=3.1.2,<4",
Expand Down Expand Up @@ -62,7 +62,7 @@ test = [
"types-pytest-lazy-fixture>=0.6.3.3",
]
dev = ["tox", "isort", "black"]
mypy = ["mypy", "types-requests", "types-click"]
mypy = ["mypy", "types-requests"]

[tool.pytest.ini_options]
addopts = [
Expand Down Expand Up @@ -124,7 +124,7 @@ commands =
[testenv:mypy]
deps = .[mypy]
commands =
mypy --ignore-missing-imports semantic_release
mypy semantic_release
[testenv:coverage]
deps = coverage[toml]
Expand Down
40 changes: 23 additions & 17 deletions semantic_release/cli/commands/main.py
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
from __future__ import annotations

import json
import logging
from pathlib import Path

import click
from click.core import ParameterSource
from git import InvalidGitRepositoryError
from git.repo.base import Repo
from rich.console import Console
Expand All @@ -16,10 +16,9 @@
GlobalCommandLineOptions,
RawConfig,
RuntimeContext,
read_toml,
)
from semantic_release.cli.const import DEFAULT_CONFIG_FILE
from semantic_release.cli.util import rprint
from semantic_release.cli.util import load_raw_config_file, rprint
from semantic_release.errors import InvalidConfiguration, NotAReleaseBranch

FORMAT = "[%(name)s] %(levelname)s %(module)s.%(funcName)s: %(message)s"
Expand All @@ -41,7 +40,7 @@
"config_file",
default=DEFAULT_CONFIG_FILE,
help="Specify a configuration file for semantic-release to use",
type=click.Path(exists=True),
type=click.Path(),
)
@click.option("--noop", "noop", is_flag=True, help="Run semantic-release in no-op mode")
@click.option(
Expand Down Expand Up @@ -123,19 +122,26 @@ def main(
)
log.debug("global cli options: %s", cli_options)

try:
if config_file.endswith(".toml"):
log.info(f"Loading TOML configuration from {config_file}")
config_text = read_toml(config_file)
elif config_file.endswith(".json"):
log.info(f"Loading JSON configuration from {config_file}")
raw_text = (Path() / config_file).resolve().read_text(encoding="utf-8")
config_text = json.loads(raw_text)["semantic_release"]
else:
*_, suffix = config_file.split(".")
ctx.fail(f"{suffix!r} is not a supported configuration format")
except (FileNotFoundError, InvalidConfiguration) as exc:
ctx.fail(str(exc))
config_path = Path(config_file)
# default no config loaded
config_text = {}
if not config_path.exists():
if ctx.get_parameter_source("config_file") not in (
ParameterSource.DEFAULT,
ParameterSource.DEFAULT_MAP,
):
ctx.fail(f"File {config_file} does not exist")

log.info(
"configuration file %s not found, using default configuration",
config_file,
)

else:
try:
config_text = load_raw_config_file(config_path)
except InvalidConfiguration as exc:
ctx.fail(str(exc))

raw_config = RawConfig.parse_obj(config_text)
try:
Expand Down
22 changes: 0 additions & 22 deletions semantic_release/cli/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,6 @@
from pathlib import Path
from typing import Any, ClassVar, Dict, List, Optional, Tuple, Union

import tomlkit
from git import Actor
from git.repo.base import Repo
from jinja2 import Environment
Expand Down Expand Up @@ -41,27 +40,6 @@
log = logging.getLogger(__name__)


def read_toml(path: str) -> dict[str, Any]:
raw_text = (Path() / path).resolve().read_text(encoding="utf-8")
try:
toml_text = tomlkit.loads(raw_text)
except tomlkit.exceptions.TOMLKitError as exc:
raise InvalidConfiguration(f"File {path!r} contains invalid TOML") from exc

# Look for [tool.semantic_release]
cfg_text = toml_text.get("tool", {}).get("semantic_release")
if cfg_text is not None:
return cfg_text
# Look for [semantic_release]
cfg_text = toml_text.get("semantic_release")
if cfg_text is not None:
return cfg_text

raise InvalidConfiguration(
f"Missing keys 'tool.semantic_release' or 'semantic_release' in {path}"
)


class HvcsClient(str, Enum):
GITHUB = "github"
GITLAB = "gitlab"
Expand Down
78 changes: 78 additions & 0 deletions semantic_release/cli/util.py
Original file line number Diff line number Diff line change
@@ -1,10 +1,22 @@
"""
Utilities for command-line functionality
"""
from __future__ import annotations

import json
import logging
import sys
from pathlib import Path
from textwrap import dedent, indent
from typing import Any

import rich
import tomlkit
from tomlkit.exceptions import TOMLKitError

from semantic_release.errors import InvalidConfiguration

log = logging.getLogger(__name__)


def rprint(msg: str) -> None:
Expand Down Expand Up @@ -32,3 +44,69 @@ def indented(msg: str, prefix: str = " " * 4) -> str:
indentation in the Python source code
"""
return indent(dedent(msg), prefix=prefix)


def parse_toml(raw_text: str) -> dict[Any, Any]:
"""
Attempts to parse raw configuration for semantic_release
using tomlkit.loads, raising InvalidConfiguration if the
TOML is invalid or there's no top level "semantic_release"
or "tool.semantic_release" keys
"""
try:
toml_text = tomlkit.loads(raw_text)
except TOMLKitError as exc:
raise InvalidConfiguration(str(exc)) from exc

# Look for [tool.semantic_release]
cfg_text = toml_text.get("tool", {}).get("semantic_release")
if cfg_text is not None:
return cfg_text
# Look for [semantic_release] or return {} if not
# found
return toml_text.get("semantic_release", {})


def load_raw_config_file(config_file: Path | str) -> dict[Any, Any]:
"""
Load raw configuration as a dict from the filename specified
by config_filename, trying the following parsing methods:
1. try to parse with tomlkit.load (guessing it's a TOML file)
2. try to parse with json.load (guessing it's a JSON file)
3. raise InvalidConfiguration if none of the above parsing
methods work
This function will also raise FileNotFoundError if it is raised
while trying to read the specified configuration file
"""

log.info("Loading configuration from %s", config_file)
raw_text = (Path() / config_file).resolve().read_text(encoding="utf-8")
try:
log.debug("Trying to parse configuration %s in TOML format", config_file)
return parse_toml(raw_text)
except InvalidConfiguration as e:
log.debug("Configuration %s is invalid TOML: %s", config_file, str(e))
log.debug("trying to parse %s as JSON", config_file)
try:
# could be a "parse_json" function but it's a one-liner here
return json.loads(raw_text)["semantic_release"]
except KeyError:
# valid configuration, but no "semantic_release" or "tool.semantic_release" top
# level key
log.debug(
"configuration has no 'semantic_release' or 'tool.semantic_release' top-level key"
)
return {}
except json.JSONDecodeError as jde:
raise InvalidConfiguration(
dedent(
f"""
None of the supported configuration parsers were able to parse
the configuration file {config_file}:
* TOML: {str(e)}
* JSON: {str(jde)}
"""
)
) from jde
94 changes: 94 additions & 0 deletions tests/command_line/test_main.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,8 @@
import json
import os
from textwrap import dedent

import git
import pytest

from semantic_release import __version__
Expand Down Expand Up @@ -30,3 +35,92 @@ def test_not_a_release_branch_exit_code_with_strict(
repo_with_git_flow_angular_commits.git.checkout("-b", "branch-does-not-exist")
result = cli_runner.invoke(main, ["--strict", "version", "--no-commit"])
assert result.exit_code != 0


@pytest.fixture
def toml_file_with_no_configuration_for_psr(tmp_path):
path = tmp_path / "config.toml"
path.write_text(
dedent(
r"""
[project]
name = "foo"
version = "1.2.0"
"""
)
)

yield path


@pytest.fixture
def json_file_with_no_configuration_for_psr(tmp_path):
path = tmp_path / "config.json"
path.write_text(json.dumps({"foo": [1, 2, 3]}))

yield path


@pytest.mark.usefixtures("repo_with_git_flow_angular_commits")
def test_default_config_is_used_when_none_in_toml_config_file(
cli_runner,
toml_file_with_no_configuration_for_psr,
):
result = cli_runner.invoke(
main,
["--noop", "--config", str(toml_file_with_no_configuration_for_psr), "version"],
)

assert result.exit_code == 0


@pytest.mark.usefixtures("repo_with_git_flow_angular_commits")
def test_default_config_is_used_when_none_in_json_config_file(
cli_runner,
json_file_with_no_configuration_for_psr,
):
result = cli_runner.invoke(
main,
["--noop", "--config", str(json_file_with_no_configuration_for_psr), "version"],
)

assert result.exit_code == 0


@pytest.mark.usefixtures("repo_with_git_flow_angular_commits")
def test_errors_when_config_file_does_not_exist_and_passed_explicitly(
cli_runner,
):
result = cli_runner.invoke(
main,
["--noop", "--config", "somenonexistantfile.123.txt", "version"],
)

assert result.exit_code == 2
assert "does not exist" in result.stderr


def test_uses_default_config_when_no_config_file_found(
tmp_path,
cli_runner,
):
# We have to initialise an empty git repository, as the example projects
# all have pyproject.toml configs which would be used by default
repo = git.Repo.init(tmp_path)
repo.git.branch("-M", "main")
with repo.config_writer("repository") as config:
config.set_value("user", "name", "semantic release testing")
config.set_value("user", "email", "not_a_real@email.com")
repo.create_remote(name="origin", url="foo@barvcs.com:user/repo.git")
repo.git.commit("-m", "feat: initial commit", "--allow-empty")

try:
os.chdir(tmp_path)
result = cli_runner.invoke(
main,
["--noop", "version"],
)

assert result.exit_code == 0
finally:
repo.close()

0 comments on commit f15753c

Please sign in to comment.