Skip to content
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
6 changes: 3 additions & 3 deletions .pre-commit-config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -7,14 +7,14 @@ repos:
- id: debug-statements

- repo: https://github.com/astral-sh/ruff-pre-commit
rev: v0.12.3
rev: v0.12.4
hooks:
- id: ruff
- id: ruff-check
args: [--fix, --exit-non-zero-on-fix, --show-fixes]
- id: ruff-format

- repo: https://github.com/pre-commit/mirrors-mypy
rev: v1.16.1
rev: v1.17.0
hooks:
- id: mypy
args: [--strict]
Expand Down
20 changes: 20 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,25 @@
# Changelog

## Unreleased

### Added

- add `setuptools-scm` console_scripts entry point to make the CLI directly executable
- make Mercurial command configurable by environment variable `SETUPTOOLS_SCM_HG_COMMAND`

### Changed

- add `pip` to test optional dependencies for improved uv venv compatibility
- migrate to selectable entrypoints for better extensibility
- improve typing for entry_points

### Fixed

- fix #1145: ensure GitWorkdir.get_head_date returns consistent UTC dates regardless of local timezone
- fix #687: ensure calendar versioning tests use consistent time context to prevent failures around midnight in non-UTC timezones
- reintroduce Python 3.9 entrypoints shim for compatibility
- fix #1136: update customizing.md to fix missing import

## v8.3.1

### Fixed
Expand Down
4 changes: 0 additions & 4 deletions changelog.d/20250612_144312_me_hg_command.md

This file was deleted.

1 change: 0 additions & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,6 @@ dependencies = [
"setuptools", # >= 61",
'tomli>=1; python_version < "3.11"',
'typing-extensions; python_version < "3.10"',
'importlib-metadata>=4.6; python_version < "3.10"',
]
[project.optional-dependencies]
docs = [
Expand Down
29 changes: 18 additions & 11 deletions src/setuptools_scm/_entrypoints.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,22 +21,29 @@
from ._config import Configuration
from ._config import ParseFunction

if sys.version_info[:2] < (3, 10):
import importlib_metadata as im
else:
from importlib import metadata as im

from importlib import metadata as im
Copy link

Copilot AI Jul 23, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This import will fail on Python 3.9 since importlib.metadata doesn't have the same API as importlib_metadata. The code should only import this for Python 3.10+ or handle the ImportError.

Copilot uses AI. Check for mistakes.


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


def entry_points(**kw: Any) -> im.EntryPoints:
if sys.version_info[:2] < (3, 10):
import importlib_metadata as im
else:
import importlib.metadata as im
if sys.version_info[:2] < (3, 10):

def entry_points(*, group: str, name: str | None = None) -> list[im.EntryPoint]:
# Python 3.9: entry_points() returns dict, need to handle filtering manually

eps = im.entry_points() # Returns dict

group_eps = eps.get(group, [])
if name is not None:
return [ep for ep in group_eps if ep.name == name]
return group_eps
else:

return im.entry_points(**kw)
def entry_points(*, group: str, name: str | None = None) -> im.EntryPoints:
kw = {"group": group}
if name is not None:
kw["name"] = name
return im.entry_points(**kw)


def version_from_entrypoint(
Expand Down
16 changes: 11 additions & 5 deletions src/setuptools_scm/_run_cmd.py
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,16 @@ def parse_success(
return parse(self.stdout)


KEEP_GIT_ENV = (
"GIT_CEILING_DIRECTORIES",
"GIT_EXEC_PATH",
"GIT_SSH",
"GIT_SSH_COMMAND",
"GIT_AUTHOR_DATE",
"GIT_COMMITTER_DATE",
)


def no_git_env(env: Mapping[str, str]) -> dict[str, str]:
# adapted from pre-commit
# Too many bugs dealing with environment variables and GIT:
Expand All @@ -95,11 +105,7 @@ def no_git_env(env: Mapping[str, str]) -> dict[str, str]:
if k.startswith("GIT_"):
log.debug("%s: %s", k, v)
return {
k: v
for k, v in env.items()
if not k.startswith("GIT_")
or k
in ("GIT_CEILING_DIRECTORIES", "GIT_EXEC_PATH", "GIT_SSH", "GIT_SSH_COMMAND")
k: v for k, v in env.items() if not k.startswith("GIT_") or k in KEEP_GIT_ENV
}


Expand Down
8 changes: 7 additions & 1 deletion src/setuptools_scm/git.py
Original file line number Diff line number Diff line change
Expand Up @@ -123,7 +123,13 @@ def parse_timestamp(timestamp_text: str) -> date | None:
return None
if sys.version_info < (3, 11) and timestamp_text.endswith("Z"):
timestamp_text = timestamp_text[:-1] + "+00:00"
return datetime.fromisoformat(timestamp_text).date()

# Convert to UTC to ensure consistent date regardless of local timezone
dt = datetime.fromisoformat(timestamp_text)
log.debug("dt: %s", dt)
dt_utc = dt.astimezone(timezone.utc).date()
log.debug("dt utc: %s", dt_utc)
return dt_utc

res = run_git(
[
Expand Down
20 changes: 15 additions & 5 deletions src/setuptools_scm/version.py
Original file line number Diff line number Diff line change
Expand Up @@ -212,11 +212,12 @@ def meta(
branch: str | None = None,
config: _config.Configuration,
node_date: date | None = None,
time: datetime | None = None,
) -> ScmVersion:
parsed_version = _parse_tag(tag, preformatted, config)
log.info("version %s -> %s", tag, parsed_version)
assert parsed_version is not None, f"Can't parse version {tag}"
return ScmVersion(
scm_version = ScmVersion(
parsed_version,
distance=distance,
node=node,
Expand All @@ -226,6 +227,9 @@ def meta(
config=config,
node_date=node_date,
)
if time is not None:
scm_version = dataclasses.replace(scm_version, time=time)
return scm_version


def guess_next_version(tag_version: ScmVersion) -> str:
Expand Down Expand Up @@ -365,19 +369,25 @@ def guess_next_date_ver(
head_date = node_date or today
# compute patch
if match is None:
tag_date = today
# For legacy non-date tags, always use patch=0 (treat as "other day")
# Use yesterday to ensure tag_date != head_date
from datetime import timedelta

tag_date = head_date - timedelta(days=1)
else:
tag_date = (
datetime.strptime(match.group("date"), date_fmt)
.replace(tzinfo=timezone.utc)
.date()
)
if tag_date == head_date:
patch = "0" if match is None else (match.group("patch") or "0")
patch = int(patch) + 1
assert match is not None
# Same day as existing date tag - increment patch
patch = int(match.group("patch") or "0") + 1
else:
# Different day or legacy non-date tag - use patch 0
if tag_date > head_date and match is not None:
# warn on future times
# warn on future times (only for actual date tags, not legacy)
warnings.warn(
f"your previous tag ({tag_date}) is ahead your node date ({head_date})"
)
Expand Down
30 changes: 30 additions & 0 deletions testing/test_git.py
Original file line number Diff line number Diff line change
Expand Up @@ -515,6 +515,36 @@ def test_git_getdate_git_2_45_0_plus(
assert git_wd.get_head_date() == date(2024, 4, 30)


def test_git_getdate_timezone_consistency(
wd: WorkDir, monkeypatch: pytest.MonkeyPatch
) -> None:
"""Test that get_head_date returns consistent UTC dates regardless of local timezone.

This test forces a git commit with a timestamp that represents a time
after midnight in a positive timezone offset but still the previous day in UTC.
This is the exact scenario that was causing test failures in issue #1145.
"""
# Create a timestamp that's problematic:
# - In Europe/Berlin (UTC+2): 2025-06-12 00:30:00 (June 12th)
# - In UTC: 2025-06-11 22:30:00 (June 11th)
problematic_timestamp = "2025-06-12T00:30:00+02:00"

# Force git to use this specific timestamp for the commit
monkeypatch.setenv("GIT_AUTHOR_DATE", problematic_timestamp)
monkeypatch.setenv("GIT_COMMITTER_DATE", problematic_timestamp)

wd.commit_testfile()

git_wd = git.GitWorkdir(wd.cwd)
result_date = git_wd.get_head_date()

# The correct behavior is to return the UTC date (2025-06-11)
# If the bug is present, it would return the timezone-local date (2025-06-12)
expected_utc_date = date(2025, 6, 11)

assert result_date == expected_utc_date


@pytest.fixture
def signed_commit_wd(monkeypatch: pytest.MonkeyPatch, wd: WorkDir) -> WorkDir:
if not has_command("gpg", args=["--version"], warn=False):
Expand Down
37 changes: 29 additions & 8 deletions testing/test_version.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,9 @@

from dataclasses import replace
from datetime import date
from datetime import datetime
from datetime import timedelta
from datetime import timezone
from typing import Any

import pytest
Expand Down Expand Up @@ -269,11 +271,14 @@ def test_custom_version_schemes() -> None:
assert custom_computed == no_guess_dev_version(version)


# Fixed time for consistent test behavior across timezone boundaries
# This prevents issue #687 where tests failed around midnight in non-UTC timezones
_TEST_TIME = datetime(2023, 12, 15, 12, 0, 0, tzinfo=timezone.utc)


def date_offset(base_date: date | None = None, days_offset: int = 0) -> date:
if base_date is None:
from setuptools_scm.version import _source_epoch_or_utc_now

base_date = _source_epoch_or_utc_now().date()
base_date = _TEST_TIME.date()
return base_date - timedelta(days=days_offset)


Expand Down Expand Up @@ -304,12 +309,23 @@ def date_to_str(
id="leading 0s",
),
pytest.param(
meta(date_to_str(days_offset=3), config=c_non_normalize, dirty=True),
meta(
date_to_str(days_offset=3),
config=c_non_normalize,
dirty=True,
time=_TEST_TIME,
),
date_to_str() + ".0.dev0",
id="dirty other day",
),
pytest.param(
meta(date_to_str(), config=c_non_normalize, distance=2, branch="default"),
meta(
date_to_str(),
config=c_non_normalize,
distance=2,
branch="default",
time=_TEST_TIME,
),
date_to_str() + ".1.dev2",
id="normal branch",
),
Expand Down Expand Up @@ -382,8 +398,8 @@ def test_calver_by_date(version: ScmVersion, expected_next: str) -> None:
[
pytest.param(meta("1.0.0", config=c), "1.0.0", id="SemVer exact stays"),
pytest.param(
meta("1.0.0", config=c_non_normalize, dirty=True),
"09.02.13.1.dev0",
meta("1.0.0", config=c_non_normalize, dirty=True, time=_TEST_TIME),
"23.12.15.0.dev0",
id="SemVer dirty is replaced by date",
marks=pytest.mark.filterwarnings("ignore:.*legacy version.*:UserWarning"),
),
Expand All @@ -397,7 +413,12 @@ def test_calver_by_date_semver(version: ScmVersion, expected_next: str) -> None:
def test_calver_by_date_future_warning() -> None:
with pytest.warns(UserWarning, match="your previous tag*"):
calver_by_date(
meta(date_to_str(days_offset=-2), config=c_non_normalize, distance=2)
meta(
date_to_str(days_offset=-2),
config=c_non_normalize,
distance=2,
time=_TEST_TIME,
)
)


Expand Down
Loading