Skip to content

Commit

Permalink
Raise exception in the case extension is not installed
Browse files Browse the repository at this point in the history
There are situations that the user tries to update a previously
generated project with PyScaffold, but the extensions are not installed
in the current venv (like #506).

These changes try to make this situation obvious and ask for the user to
install the missing extensions.

I believe that it is better to panic and raise an exception instead of
proceed running PyScaffold, because there are cases that the extensions
completely change the desired behaviour (e.g. namespace indirectly via
custom_extension).
  • Loading branch information
abravalheri committed Sep 30, 2021
1 parent 9b561a8 commit 1a3fdb7
Show file tree
Hide file tree
Showing 4 changed files with 41 additions and 4 deletions.
14 changes: 13 additions & 1 deletion src/pyscaffold/exceptions.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
import logging
import sys
import traceback
from typing import Optional, cast
from typing import Optional, Sequence, cast

if sys.version_info[:2] >= (3, 8):
# TODO: Import directly (no need for conditional) when `python_requires = >= 3.8`
Expand Down Expand Up @@ -141,6 +141,18 @@ def __init__(self, message=None, *args, **kwargs):
super().__init__(message, *args, **kwargs)


class ExtensionNotFound(ImportError):
"""The following extensions were not found: {extensions}.
Please make sure you have the required versions installed.
You can look for official extensions at https://github.com/pyscaffold.
"""

def __init__(self, extensions: Sequence[str]):
message = cast(str, self.__doc__)
message = message.format(extensions=extensions, version=pyscaffold_version)
super().__init__(message)


class ErrorLoadingExtension(RuntimeError):
"""There was an error loading '{extension}'.
Please make sure you have installed a version of the extension that is compatible
Expand Down
5 changes: 5 additions & 0 deletions src/pyscaffold/extensions/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,11 @@

ENTRYPOINT_GROUP = "pyscaffold.cli"

NO_LONGER_NEEDED = {"pyproject", "tox"}
"""Extensions that are no longer needed and are now part of PyScaffold itself"""

# TODO: NO_LONGER_SUPPORTED = {"no_pyproject"}


class Extension:
"""Base class for PyScaffold's extensions
Expand Down
13 changes: 11 additions & 2 deletions src/pyscaffold/info.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
from enum import Enum
from operator import itemgetter
from pathlib import Path
from typing import Optional, cast, overload
from typing import Optional, Set, cast, overload

import appdirs
from configupdater import ConfigUpdater
Expand All @@ -18,6 +18,7 @@
from . import __name__ as PKG_NAME
from . import shell, toml
from .exceptions import (
ExtensionNotFound,
GitNotConfigured,
GitNotInstalled,
ImpossibleToFindConfigDir,
Expand Down Expand Up @@ -170,6 +171,7 @@ def project(
:class:`~.NoPyScaffoldProject`: when project was not generated with PyScaffold
"""
# Lazily load the following function to avoid circular dependencies
from .extensions import NO_LONGER_NEEDED # TODO: NO_LONGER_SUPPORTED
from .extensions import list_from_entry_points as list_extensions

opts = copy.deepcopy({k: v for k, v in opts.items() if not callable(v)})
Expand All @@ -191,7 +193,7 @@ def project(
"author": metadata.get("author"),
"email": metadata.get("author_email") or metadata.get("author-email"),
"url": metadata.get("url"),
"description": metadata.get("description"),
"description": metadata.get("description", "").strip(),
"license": license and best_fit_license(license),
}
existing = {k: v for k, v in existing.items() if v} # Filter out non stored values
Expand All @@ -201,14 +203,21 @@ def project(
opts = {**existing, **opts}

# Complement the cli extensions with the ones from configuration
not_found_ext: Set[str] = set()
if "extensions" in pyscaffold:
cfg_extensions = parse_extensions(pyscaffold.pop("extensions", ""))
opt_extensions = {ext.name for ext in opts.setdefault("extensions", [])}
add_extensions = cfg_extensions - opt_extensions

other_ext = list_extensions(filtering=lambda e: e.name in add_extensions)
not_found_ext = add_extensions - {e.name for e in other_ext} - NO_LONGER_NEEDED
opts["extensions"] = deterministic_sort(opts["extensions"] + other_ext)

if not_found_ext:
raise ExtensionNotFound(list(not_found_ext))

# TODO: NO_LONGER_SUPPORTED => raise Exception(use older PyScaffold)

# The remaining values in the pyscaffold section can be added to opts
# if not specified yet. Useful when extensions define other options.
for key, value in pyscaffold.items():
Expand Down
13 changes: 12 additions & 1 deletion tests/test_info.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,9 @@
from unittest.mock import MagicMock as Mock

import pytest
from configupdater import ConfigUpdater

from pyscaffold import cli, exceptions, info, repo, structure, templates
from pyscaffold import actions, cli, exceptions, info, repo, structure, templates


def test_username_with_git(git_mock):
Expand Down Expand Up @@ -147,6 +148,16 @@ def test_project_old_setupcfg(tmpfolder):
info.project({}, config_path=demoapp)


def test_project_extensions_not_found(tmpfolder):
_, opts = actions.get_default_options({}, {})
cfg = ConfigUpdater().read_string(templates.setup_cfg(opts))
cfg["pyscaffold"]["extensions"] = "x_foo_bar_x"
(tmpfolder / "setup.cfg").write(str(cfg))
with pytest.raises(exceptions.ExtensionNotFound) as exc:
info.project(opts)
assert "x_foo_bar_x" in str(exc.value)


@pytest.mark.no_fake_config_dir
def test_config_dir_error(monkeypatch):
# no_fake_config_dir => avoid previous mock of config_dir
Expand Down

0 comments on commit 1a3fdb7

Please sign in to comment.