diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 2a9b64db..c840b357 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -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] diff --git a/CHANGELOG.md b/CHANGELOG.md index c8823c7e..249bcce7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/changelog.d/20250612_144312_me_hg_command.md b/changelog.d/20250612_144312_me_hg_command.md deleted file mode 100644 index ff109f13..00000000 --- a/changelog.d/20250612_144312_me_hg_command.md +++ /dev/null @@ -1,4 +0,0 @@ -### Added - -- make Mercurial command configurable by environment variable `SETUPTOOLS_SCM_HG_COMMAND` - diff --git a/pyproject.toml b/pyproject.toml index fd5745b7..ffbd9778 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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 = [ diff --git a/src/setuptools_scm/_entrypoints.py b/src/setuptools_scm/_entrypoints.py index 3333eb5c..23ee8ce4 100644 --- a/src/setuptools_scm/_entrypoints.py +++ b/src/setuptools_scm/_entrypoints.py @@ -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 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( diff --git a/src/setuptools_scm/_run_cmd.py b/src/setuptools_scm/_run_cmd.py index 5d5ec15e..2dff6369 100644 --- a/src/setuptools_scm/_run_cmd.py +++ b/src/setuptools_scm/_run_cmd.py @@ -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: @@ -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 } diff --git a/src/setuptools_scm/git.py b/src/setuptools_scm/git.py index 5be2f89d..4773f49c 100644 --- a/src/setuptools_scm/git.py +++ b/src/setuptools_scm/git.py @@ -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( [ diff --git a/src/setuptools_scm/version.py b/src/setuptools_scm/version.py index 29803fcd..eb9dcf63 100644 --- a/src/setuptools_scm/version.py +++ b/src/setuptools_scm/version.py @@ -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, @@ -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: @@ -365,7 +369,11 @@ 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) @@ -373,11 +381,13 @@ def guess_next_date_ver( .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})" ) diff --git a/testing/test_git.py b/testing/test_git.py index 9186b1a6..9ef1f14a 100644 --- a/testing/test_git.py +++ b/testing/test_git.py @@ -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): diff --git a/testing/test_version.py b/testing/test_version.py index 32a65c0d..c7789df4 100644 --- a/testing/test_version.py +++ b/testing/test_version.py @@ -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 @@ -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) @@ -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", ), @@ -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"), ), @@ -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, + ) )