Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

correctly handle project config overrides when the version keyword is used together with pyproject.toml #947

Merged
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@

### Changed

- ensure the setuptools version keyword correctly load pyproject.toml configuration
- add build and wheel to the test requirements for regression testing
- move internal toml handling to own module
2 changes: 2 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -57,8 +57,10 @@ rich = [
"rich",
]
test = [
"build",
"pytest",
"rich",
"wheel",
]
toml = [
]
Expand Down
9 changes: 5 additions & 4 deletions src/setuptools_scm/_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,8 @@
import os
import re
import warnings
from pathlib import Path
from typing import Any
from typing import Callable
from typing import Pattern
from typing import Protocol

Expand Down Expand Up @@ -114,7 +114,7 @@ def from_file(
cls,
name: str | os.PathLike[str] = "pyproject.toml",
dist_name: str | None = None,
_load_toml: Callable[[str], dict[str, Any]] | None = None,
_require_section: bool = True,
**kwargs: Any,
) -> Configuration:
"""
Expand All @@ -124,11 +124,12 @@ def from_file(
not contain the [tool.setuptools_scm] section.
"""

pyproject_data = _read_pyproject(name, _load_toml=_load_toml)
pyproject_data = _read_pyproject(Path(name), require_section=_require_section)
args = _get_args_for_pyproject(pyproject_data, dist_name, kwargs)

args.update(read_toml_overrides(args["dist_name"]))
return cls.from_data(relative_to=name, data=args)
relative_to = args.pop("relative_to", name)
return cls.from_data(relative_to=relative_to, data=args)

@classmethod
def from_data(
Expand Down
2 changes: 1 addition & 1 deletion src/setuptools_scm/_entrypoints.py
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@ def entry_points(group: str) -> EntryPoints:


def version_from_entrypoint(
config: Configuration, entrypoint: str, root: _t.PathT
config: Configuration, *, entrypoint: str, root: _t.PathT
) -> version.ScmVersion | None:
from .discover import iter_matching_entrypoints

Expand Down
16 changes: 10 additions & 6 deletions src/setuptools_scm/_get_version_impl.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,18 +32,22 @@ def parse_scm_version(config: Configuration) -> ScmVersion | None:
)
return parse_result
else:
entrypoint = "setuptools_scm.parse_scm"
root = config.absolute_root
return _entrypoints.version_from_entrypoint(config, entrypoint, root)
return _entrypoints.version_from_entrypoint(
config,
entrypoint="setuptools_scm.parse_scm",
root=config.absolute_root,
)
except _run_cmd.CommandNotFoundError as e:
_log.exception("command %s not found while parsing the scm, using fallbacks", e)
return None


def parse_fallback_version(config: Configuration) -> ScmVersion | None:
entrypoint = "setuptools_scm.parse_scm_fallback"
root = config.fallback_root
return _entrypoints.version_from_entrypoint(config, entrypoint, root)
return _entrypoints.version_from_entrypoint(
config,
entrypoint="setuptools_scm.parse_scm_fallback",
root=config.fallback_root,
)


def parse_version(config: Configuration) -> ScmVersion | None:
Expand Down
51 changes: 20 additions & 31 deletions src/setuptools_scm/_integration/pyproject_reading.py
Original file line number Diff line number Diff line change
@@ -1,26 +1,22 @@
from __future__ import annotations

import os
import sys
import warnings
from typing import Any
from typing import Callable
from typing import Dict
from pathlib import Path
from typing import NamedTuple
from typing import TYPE_CHECKING

from .. import _log
from .setuptools import read_dist_name_from_setup_cfg
from .toml import read_toml_content
from .toml import TOML_RESULT

if TYPE_CHECKING:
from typing_extensions import TypeAlias

log = _log.log.getChild("pyproject_reading")

_ROOT = "root"
TOML_RESULT: TypeAlias = Dict[str, Any]
TOML_LOADER: TypeAlias = Callable[[str], TOML_RESULT]


class PyProjectData(NamedTuple):
name: str | os.PathLike[str]
path: Path
tool_name: str
project: TOML_RESULT
section: TOML_RESULT
Expand All @@ -30,31 +26,24 @@ def project_name(self) -> str | None:
return self.project.get("name")


def lazy_toml_load(data: str) -> TOML_RESULT:
if sys.version_info >= (3, 11):
from tomllib import loads
else:
from tomli import loads

return loads(data)


def read_pyproject(
name: str | os.PathLike[str] = "pyproject.toml",
path: Path = Path("pyproject.toml"),
tool_name: str = "setuptools_scm",
_load_toml: TOML_LOADER | None = None,
require_section: bool = True,
) -> PyProjectData:
if _load_toml is None:
_load_toml = lazy_toml_load
with open(name, encoding="UTF-8") as strm:
data = strm.read()
defn = _load_toml(data)
defn = read_toml_content(path, None if require_section else {})
try:
section = defn.get("tool", {})[tool_name]
except LookupError as e:
raise LookupError(f"{name} does not contain a tool.{tool_name} section") from e
error = f"{path} does not contain a tool.{tool_name} section"
if require_section:
raise LookupError(error) from e
else:
log.warning("toml section missing %r", error)
section = {}

project = defn.get("project", {})
return PyProjectData(name, tool_name, project, section)
return PyProjectData(path, tool_name, project, section)


def get_args_for_pyproject(
Expand All @@ -68,7 +57,7 @@ def get_args_for_pyproject(
if "relative_to" in section:
relative = section.pop("relative_to")
warnings.warn(
f"{pyproject.name}: at [tool.{pyproject.tool_name}]\n"
f"{pyproject.path}: at [tool.{pyproject.tool_name}]\n"
f"ignoring value relative_to={relative!r}"
" as its always relative to the config file"
)
Expand All @@ -92,5 +81,5 @@ def get_args_for_pyproject(
f"root {section[_ROOT]} is overridden"
f" by the cli arg {kwargs[_ROOT]}"
)
section.pop("root", None)
section.pop(_ROOT, None)
return {"dist_name": dist_name, **section, **kwargs}
46 changes: 21 additions & 25 deletions src/setuptools_scm/_integration/setuptools.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,6 @@
import setuptools

from .. import _config
from .._version_cls import _validate_version_cls

log = logging.getLogger(__name__)

Expand Down Expand Up @@ -63,50 +62,47 @@ def _assign_version(
_warn_on_old_setuptools()


def _log_hookstart(hook: str, dist: setuptools.Distribution) -> None:
log.debug("%s %r", hook, vars(dist.metadata))


def version_keyword(
dist: setuptools.Distribution,
keyword: str,
value: bool | dict[str, Any] | Callable[[], dict[str, Any]],
) -> None:
if not value:
return
elif value is True:
value = {}
overrides: dict[str, Any]
if value is True:
overrides = {}
elif callable(value):
value = value()
overrides = value()
else:
assert isinstance(value, dict), "version_keyword expects a dict or True"
overrides = value

assert (
"dist_name" not in value
"dist_name" not in overrides
), "dist_name may not be specified in the setup keyword "
dist_name: str | None = dist.metadata.name
_log_hookstart("version_keyword", dist)

if dist.metadata.version is not None:
warnings.warn(f"version of {dist_name} already set")
return
log.debug(
"version keyword %r",
vars(dist.metadata),
)
log.debug("dist %s %s", id(dist), id(dist.metadata))

if dist_name is None:
dist_name = read_dist_name_from_setup_cfg()
version_cls = value.pop("version_cls", None)
normalize = value.pop("normalize", True)
tag_regex = _config._check_tag_regex(
value.pop("tag_regex", _config.DEFAULT_TAG_REGEX)
)
final_version = _validate_version_cls(version_cls, normalize)

config = _config.Configuration(
dist_name=dist_name, version_cls=final_version, tag_regex=tag_regex, **value
config = _config.Configuration.from_file(
dist_name=dist_name,
_require_section=False,
**overrides,
)
_assign_version(dist, config)


def infer_version(dist: setuptools.Distribution) -> None:
log.debug(
"finalize hook %r",
vars(dist.metadata),
)
_log_hookstart("infer_version", dist)
log.debug("dist %s %s", id(dist), id(dist.metadata))
if dist.metadata.version is not None:
return # metadata already added by hook
Expand All @@ -120,6 +116,6 @@ def infer_version(dist: setuptools.Distribution) -> None:
try:
config = _config.Configuration.from_file(dist_name=dist_name)
except LookupError as e:
log.exception(e)
log.warning(e)
else:
_assign_version(dist, config)
55 changes: 55 additions & 0 deletions src/setuptools_scm/_integration/toml.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
from __future__ import annotations

import sys
from pathlib import Path
from typing import Any
from typing import Callable
from typing import cast
from typing import Dict
from typing import TYPE_CHECKING
from typing import TypedDict

if sys.version_info >= (3, 11):
from tomllib import loads as load_toml
else:
from tomli import loads as load_toml

if TYPE_CHECKING:
from typing_extensions import TypeAlias

from .. import _log

log = _log.log.getChild("toml")

TOML_RESULT: TypeAlias = Dict[str, Any]
TOML_LOADER: TypeAlias = Callable[[str], TOML_RESULT]


def read_toml_content(path: Path, default: TOML_RESULT | None = None) -> TOML_RESULT:
try:
data = path.read_text(encoding="utf-8")
except FileNotFoundError:
if default is None:
raise
else:
log.debug("%s missing, presuming default %r", path, default)
return default
else:
return load_toml(data)


class _CheatTomlData(TypedDict):
cheat: dict[str, Any]


def load_toml_or_inline_map(data: str | None) -> dict[str, Any]:
"""
load toml data - with a special hack if only a inline map is given
"""
if not data:
return {}
elif data[0] == "{":
data = "cheat=" + data
loaded: _CheatTomlData = cast(_CheatTomlData, load_toml(data))
return loaded["cheat"]
return load_toml(data)
21 changes: 1 addition & 20 deletions src/setuptools_scm/_overrides.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,11 @@
import os
import re
from typing import Any
from typing import cast
from typing import TypedDict

from . import _config
from . import _log
from . import version
from ._integration.pyproject_reading import lazy_toml_load
from ._integration.toml import load_toml_or_inline_map

log = _log.log.getChild("overrides")

Expand Down Expand Up @@ -51,23 +49,6 @@ def _read_pretended_version_for(
return None


class _CheatTomlData(TypedDict):
cheat: dict[str, Any]


def load_toml_or_inline_map(data: str | None) -> dict[str, Any]:
"""
load toml data - with a special hack if only a inline map is given
"""
if not data:
return {}
elif data[0] == "{":
data = "cheat=" + data
loaded: _CheatTomlData = cast(_CheatTomlData, lazy_toml_load(data))
return loaded["cheat"]
return lazy_toml_load(data)


def read_toml_overrides(dist_name: str | None) -> dict[str, Any]:
data = read_named_env(name="OVERRIDES", dist_name=dist_name)
return load_toml_or_inline_map(data)
10 changes: 5 additions & 5 deletions src/setuptools_scm/_version_cls.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
from __future__ import annotations

from logging import getLogger
from typing import cast
from typing import Type
from typing import Union
Expand All @@ -11,6 +10,9 @@
except ImportError:
from setuptools.extern.packaging.version import InvalidVersion # type: ignore
from setuptools.extern.packaging.version import Version as Version # type: ignore
from . import _log

log = _log.log.getChild("version_cls")


class NonNormalizedVersion(Version):
Expand Down Expand Up @@ -41,10 +43,8 @@ def __repr__(self) -> str:
def _version_as_tuple(version_str: str) -> tuple[int | str, ...]:
try:
parsed_version = Version(version_str)
except InvalidVersion:
log = getLogger(__name__).parent
assert log is not None
log.error("failed to parse version %s", version_str)
except InvalidVersion as e:
log.error("failed to parse version %s: %s", e, version_str)
return (version_str,)
else:
version_fields: tuple[int | str, ...] = parsed_version.release
Expand Down
Loading
Loading