Skip to content

Commit

Permalink
fix(cli): enable subcommand help even if config is invalid
Browse files Browse the repository at this point in the history
Refactors configuration loading to use lazy loading by subcommands
triggered by the property access of the runtime_ctx object. Resolves
the issues when running `--help` on subcommands when a configuration
is invalid

Resolves: #840
  • Loading branch information
codejedi365 committed Mar 19, 2024
1 parent 1d53879 commit 91d221a
Show file tree
Hide file tree
Showing 7 changed files with 161 additions and 100 deletions.
9 changes: 5 additions & 4 deletions semantic_release/cli/commands/changelog.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@
from semantic_release.cli.util import noop_report

if TYPE_CHECKING:
from semantic_release.cli.config import RuntimeContext
from semantic_release.cli.commands.cli_context import CliContextObj
from semantic_release.version import Version

log = logging.getLogger(__name__)
Expand All @@ -34,10 +34,11 @@
default=None,
help="Post the generated release notes to the remote VCS's release for this tag",
)
@click.pass_context
def changelog(ctx: click.Context, release_tag: str | None = None) -> None:
@click.pass_obj
def changelog(cli_ctx: CliContextObj, release_tag: str | None = None) -> None:
"""Generate and optionally publish a changelog for your project"""
runtime: RuntimeContext = ctx.obj
ctx = click.get_current_context()
runtime = cli_ctx.runtime_ctx
repo = runtime.repo
parser = runtime.commit_parser
translator = runtime.version_translator
Expand Down
100 changes: 100 additions & 0 deletions semantic_release/cli/commands/cli_context.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
from __future__ import annotations

import logging
from pathlib import Path
from typing import TYPE_CHECKING

import click
from click.core import ParameterSource
from git import InvalidGitRepositoryError
from pydantic import ValidationError

from semantic_release.cli.config import (
RawConfig,
RuntimeContext,
)
from semantic_release.cli.util import load_raw_config_file, rprint
from semantic_release.errors import InvalidConfiguration, NotAReleaseBranch

if TYPE_CHECKING:
from semantic_release.cli.config import GlobalCommandLineOptions

class CliContext(click.Context):
obj: CliContextObj


class CliContextObj:
def __init__(
self,
ctx: click.Context,
logger: logging.Logger,
global_opts: GlobalCommandLineOptions,
) -> None:
self._runtime_ctx: RuntimeContext | None = None
self.ctx = ctx
self.logger = logger
self.global_opts = global_opts

@property
def runtime_ctx(self) -> RuntimeContext:
"""
Lazy load the runtime context. This is done to avoid configuration loading when
the command is not run. This is useful for commands like `--help` and `--version`
"""
if self._runtime_ctx is None:
self._runtime_ctx = self._init_runtime_ctx()
return self._runtime_ctx

def _init_runtime_ctx(self) -> RuntimeContext:
config_path = Path(self.global_opts.config_file)
conf_file_exists = config_path.exists()
was_conf_file_user_provided = bool(
self.ctx.get_parameter_source("config_file")
not in (
ParameterSource.DEFAULT,
ParameterSource.DEFAULT_MAP,
)
)

try:
if was_conf_file_user_provided and not conf_file_exists:
raise FileNotFoundError(
f"File {self.global_opts.config_file} does not exist"
)

config_obj = (
{} if not conf_file_exists else load_raw_config_file(config_path)
)
if not config_obj:
self.logger.info(
"configuration empty, falling back to default configuration"
)

raw_config = RawConfig.model_validate(config_obj)
runtime = RuntimeContext.from_raw_config(
raw_config,
global_cli_options=self.global_opts,
)
except NotAReleaseBranch as exc:
rprint(f"[bold {'red' if self.global_opts.strict else 'orange1'}]{exc!s}")
# If not strict, exit 0 so other processes can continue. For example, in
# multibranch CI it might be desirable to run a non-release branch's pipeline
# without specifying conditional execution of PSR based on branch name
self.ctx.exit(2 if self.global_opts.strict else 0)
except FileNotFoundError as exc:
click.echo(str(exc), err=True)
self.ctx.exit(2)
except (
ValidationError,
InvalidConfiguration,
InvalidGitRepositoryError,
) as exc:
click.echo(str(exc), err=True)
self.ctx.exit(1)

# This allows us to mask secrets in the logging
# by applying it to all the configured handlers
for handler in logging.getLogger().handlers:
handler.addFilter(runtime.masker)

return runtime
2 changes: 1 addition & 1 deletion semantic_release/cli/commands/generate_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ def generate_config(fmt: str = "toml", is_pyproject_toml: bool = False) -> None:
your needs. For example, to append the default configuration to your pyproject.toml
file, you can use the following command:
semantic-release generate-config -f toml >> pyproject.toml
semantic-release generate-config --pyproject >> pyproject.toml
"""
# due to possible IntEnum values (which are not supported by tomlkit.dumps, see sdispater/tomlkit#237),
# we must ensure the transformation of the model to a dict uses json serializable values
Expand Down
84 changes: 13 additions & 71 deletions semantic_release/cli/commands/main.py
Original file line number Diff line number Diff line change
@@ -1,26 +1,21 @@
from __future__ import annotations

import logging
from pathlib import Path

# from typing import TYPE_CHECKING
import click
from click.core import ParameterSource
from git import InvalidGitRepositoryError
from git.repo.base import Repo
from pydantic import ValidationError
from rich.console import Console
from rich.logging import RichHandler

import semantic_release
from semantic_release.cli.commands.generate_config import generate_config
from semantic_release.cli.config import (
GlobalCommandLineOptions,
RawConfig,
RuntimeContext,
)
from semantic_release.cli.commands.cli_context import CliContextObj
from semantic_release.cli.config import GlobalCommandLineOptions
from semantic_release.cli.const import DEFAULT_CONFIG_FILE
from semantic_release.cli.util import load_raw_config_file, rprint
from semantic_release.errors import InvalidConfiguration, NotAReleaseBranch
from semantic_release.cli.util import rprint

# if TYPE_CHECKING:
# pass


FORMAT = "[%(name)s] %(levelname)s %(module)s.%(funcName)s: %(message)s"

Expand Down Expand Up @@ -94,19 +89,8 @@ def main(
],
)

log = logging.getLogger(__name__)

if ctx.invoked_subcommand == generate_config.name:
# generate-config doesn't require any of the usual setup,
# so exit out early and delegate to it
log.debug("Forwarding to %s", generate_config.name)
return

log.debug("logging level set to: %s", logging.getLevelName(log_level))
try:
repo = Repo(".", search_parent_directories=True)
except InvalidGitRepositoryError:
ctx.fail("Not in a valid Git repository")
logger = logging.getLogger(__name__)
logger.debug("logging level set to: %s", logging.getLevelName(log_level))

if noop:
rprint(
Expand All @@ -115,51 +99,9 @@ def main(
)

cli_options = GlobalCommandLineOptions(
noop=noop,
verbosity=verbosity,
config_file=config_file,
strict=strict,
noop=noop, verbosity=verbosity, config_file=config_file, strict=strict
)
log.debug("global cli options: %s", cli_options)

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))
logger.debug("global cli options: %s", cli_options)

try:
raw_config = RawConfig.model_validate(config_text)
runtime = RuntimeContext.from_raw_config(
raw_config, repo=repo, global_cli_options=cli_options
)
except NotAReleaseBranch as exc:
rprint(f"[bold {'red' if strict else 'orange1'}]{exc!s}")
# If not strict, exit 0 so other processes can continue. For example, in
# multibranch CI it might be desirable to run a non-release branch's pipeline
# without specifying conditional execution of PSR based on branch name
ctx.exit(2 if strict else 0)
except (ValidationError, InvalidConfiguration) as exc:
click.echo(str(exc), err=True)
ctx.exit(1)
ctx.obj = runtime

# This allows us to mask secrets in the logging
# by applying it to all the configured handlers
for handler in logging.getLogger().handlers:
handler.addFilter(runtime.masker)
ctx.obj = CliContextObj(ctx, logger, cli_options)
14 changes: 11 additions & 3 deletions semantic_release/cli/commands/publish.py
Original file line number Diff line number Diff line change
@@ -1,10 +1,17 @@
from __future__ import annotations

import logging
from typing import TYPE_CHECKING

import click

from semantic_release.cli.util import noop_report
from semantic_release.version import tags_and_versions

if TYPE_CHECKING:
from semantic_release.cli.commands.cli_context import CliContextObj


log = logging.getLogger(__name__)


Expand All @@ -20,10 +27,11 @@
help="The tag associated with the release to publish to",
default="latest",
)
@click.pass_context
def publish(ctx: click.Context, tag: str = "latest") -> None:
@click.pass_obj
def publish(cli_ctx: CliContextObj, tag: str = "latest") -> None:
"""Build and publish a distribution to a VCS release."""
runtime = ctx.obj
ctx = click.get_current_context()
runtime = cli_ctx.runtime_ctx
repo = runtime.repo
hvcs_client = runtime.hvcs_client
translator = runtime.version_translator
Expand Down
37 changes: 22 additions & 15 deletions semantic_release/cli/commands/version.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
import subprocess
from contextlib import nullcontext
from datetime import datetime
from typing import TYPE_CHECKING, ContextManager, Iterable
from typing import TYPE_CHECKING

import click
import shellingham # type: ignore[import]
Expand All @@ -28,10 +28,12 @@
log = logging.getLogger(__name__)

if TYPE_CHECKING: # pragma: no cover
from typing import ContextManager, Iterable

from git import Repo
from git.refs.tag import Tag

from semantic_release.cli.config import RuntimeContext
from semantic_release.cli.commands.cli_context import CliContextObj
from semantic_release.version import VersionTranslator
from semantic_release.version.declaration import VersionDeclarationABC

Expand Down Expand Up @@ -213,9 +215,9 @@ def shell(cmd: str, *, check: bool = True) -> subprocess.CompletedProcess:
is_flag=True,
help="Skip building the current project",
)
@click.pass_context
@click.pass_obj
def version( # noqa: C901
ctx: click.Context,
cli_ctx: CliContextObj,
print_only: bool = False,
print_only_tag: bool = False,
print_last_released: bool = False,
Expand All @@ -231,22 +233,27 @@ def version( # noqa: C901
build_metadata: str | None = None,
skip_build: bool = False,
) -> str:
r"""
"""
Detect the semantically correct next version that should be applied to your
project.
\b
By default:
* Write this new version to the project metadata locations
specified in the configuration file
* Create a new commit with these locations and any other assets configured
to be included in a release
* Tag this commit according the configured format, with a tag that uniquely
identifies the version being released.
* Push the new tag and commit to the remote for the repository
* Create a release (if supported) in the remote VCS for this tag
* Write this new version to the project metadata locations specified
in the configuration file
* Create a new commit with these locations and any other assets configured
to be included in a release
* Tag this commit according the configured format, with a tag that uniquely
identifies the version being released.
* Push the new tag and commit to the remote for the repository
* Create a release (if supported) in the remote VCS for this tag
"""
runtime: RuntimeContext = ctx.obj
ctx = click.get_current_context()
runtime = cli_ctx.runtime_ctx
repo = runtime.repo
translator = runtime.version_translator

Expand Down

0 comments on commit 91d221a

Please sign in to comment.