diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index f6ba7a3f..511c0c62 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -86,6 +86,18 @@ Tests can be run with `tox`. Some basic code style tests can be run with `tox -e flake8` and most other targets run the test suite with various supported Python interpreters. +Tests use the real date by default. For reproducible builds, set +`STDNUM_TEST_DATE` to an explicit date in `YYYY-MM-DD` format, for example +`STDNUM_TEST_DATE=2026-09-21 tox -e py312`. The same variable works with a +direct `pytest` invocation. Invalid or empty values fail with a configuration +error. + +This test-only option replaces `datetime.date.today()` during the test session; +it does not change the host clock or the installed library. Date construction +still returns ordinary date objects. Choose a reference date appropriate for +the release being tested. Leave the variable unset for development and +scheduled tests so that changes caused by the passage of time remain visible. + Module implementations have a couple of smaller test cases that also serve as basic documentation of the happy flow. diff --git a/conftest.py b/conftest.py new file mode 100644 index 00000000..1038c25a --- /dev/null +++ b/conftest.py @@ -0,0 +1,72 @@ +# conftest.py - optional reference date for tests +# +# Copyright (C) 2026 Ryan Duguid +# +# This library is free software; you can redistribute it and/or +# modify it under the terms of the GNU Lesser General Public +# License as published by the Free Software Foundation; either +# version 2.1 of the License, or (at your option) any later version. +# +# This library is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +# Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public +# License along with this library; if not, see . + +"""Pytest configuration for python-stdnum.""" + +import datetime +import os +from unittest.mock import patch + +import pytest + + +pytest_plugins = ['pytester'] + + +def pytest_configure(config): + """Validate the optional reference date before collecting tests.""" + value = os.environ.get('STDNUM_TEST_DATE') + config._stdnum_reference_date = None + if value is None: + return + try: + fixed_date = datetime.date.fromisoformat(value) + if fixed_date.isoformat() != value: + raise ValueError() + except ValueError: + raise pytest.UsageError('STDNUM_TEST_DATE must be a valid date in YYYY-MM-DD format') + config._stdnum_reference_date = fixed_date + + +@pytest.fixture(scope='session', autouse=True) +def reference_date(request): + """Optionally replace date.today() for the duration of the test session.""" + fixed_date = request.config._stdnum_reference_date + if fixed_date is None: + yield + return + + real_date = datetime.date + + class DateMeta(type): + """Keep isinstance checks compatible with ordinary date objects.""" + + def __instancecheck__(cls, instance): + return isinstance(instance, real_date) + + class ReferenceDate(real_date, metaclass=DateMeta): + """Return ordinary dates, overriding only the current date.""" + + def __new__(cls, *args, **kwargs): + return real_date(*args, **kwargs) + + @classmethod + def today(cls): + return fixed_date + + with patch.object(datetime, 'date', ReferenceDate): + yield diff --git a/tests/test_reference_date.py b/tests/test_reference_date.py new file mode 100644 index 00000000..4395db63 --- /dev/null +++ b/tests/test_reference_date.py @@ -0,0 +1,78 @@ +# test_reference_date.py - test the optional reference date +# +# Copyright (C) 2026 Ryan Duguid +# +# This library is free software; you can redistribute it and/or +# modify it under the terms of the GNU Lesser General Public +# License as published by the Free Software Foundation; either +# version 2.1 of the License, or (at your option) any later version. +# +# This library is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +# Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public +# License along with this library; if not, see . + +"""Check the test reference date in isolated pytest processes.""" + +from pathlib import Path + +import pytest + + +@pytest.fixture +def clock_suite(pytester: pytest.Pytester, monkeypatch: pytest.MonkeyPatch) -> pytest.Pytester: + """Install the real configuration in an isolated test directory.""" + # These child suites test pytest configuration, not library coverage. + monkeypatch.delenv('COV_CORE_DATAFILE', raising=False) + source = Path(__file__).resolve().parent.parent / 'conftest.py' + pytester.makeconftest(source.read_text() + """ +import datetime +_original_date = datetime.date + +def pytest_sessionfinish(session): + assert datetime.date is _original_date +""") + pytester.makepyfile(""" +import datetime +import os + +real_date = datetime.date +real_today = datetime.date.today() + +def test_date(): + value = os.environ.get('STDNUM_TEST_DATE') + expected = real_date.fromisoformat(value) if value is not None else real_today + assert datetime.date.today() == expected + assert datetime.date(2000, 2, 29) == real_date(2000, 2, 29) + assert repr(datetime.date(2000, 2, 29)) == 'datetime.date(2000, 2, 29)' + assert isinstance(real_date(2000, 2, 29), datetime.date) + assert isinstance(datetime.datetime.now().date(), datetime.date) + if value is None: + assert datetime.date is real_date +""") + return pytester + + +def test_default_uses_real_date(clock_suite: pytest.Pytester, monkeypatch: pytest.MonkeyPatch) -> None: + """No reference date leaves the date class and clock unchanged.""" + monkeypatch.delenv('STDNUM_TEST_DATE', raising=False) + clock_suite.runpytest_subprocess('-q').assert_outcomes(passed=1) + + +@pytest.mark.parametrize('value', ['2024-01-01', '2040-04-24', '2024-02-29']) +def test_explicit_reference_date(clock_suite: pytest.Pytester, monkeypatch: pytest.MonkeyPatch, value: str) -> None: + """A requested date applies during tests and is restored afterwards.""" + monkeypatch.setenv('STDNUM_TEST_DATE', value) + clock_suite.runpytest_subprocess('-q').assert_outcomes(passed=1) + + +@pytest.mark.parametrize('value', ['', 'invalid', '2023-02-29', '20240101']) +def test_invalid_reference_date(clock_suite: pytest.Pytester, monkeypatch: pytest.MonkeyPatch, value: str) -> None: + """Invalid dates fail with an actionable configuration error.""" + monkeypatch.setenv('STDNUM_TEST_DATE', value) + result = clock_suite.runpytest_subprocess('-q') + assert result.ret == pytest.ExitCode.USAGE_ERROR + assert 'STDNUM_TEST_DATE must be a valid date in YYYY-MM-DD format' in result.stderr.str() diff --git a/tox.ini b/tox.ini index fec260d4..4676e83c 100644 --- a/tox.ini +++ b/tox.ini @@ -6,6 +6,7 @@ skip_missing_interpreters = true deps = pytest pytest-cov commands = pytest +passenv = STDNUM_TEST_DATE setenv= PYTHONWARNINGS=all @@ -33,6 +34,7 @@ setenv= [testenv:mypy] skip_install = true deps = mypy<2.0 # required to keep Python 3.9 support + pytest<9 # pytest 9 syntax exceeds the Python 3.9 type-check target types-requests zeep commands =