├── docs ├── _static │ └── empty ├── changelog.rst ├── api-overview.png ├── index.rst ├── Makefile ├── conf.py ├── api.rst └── examples.rst ├── tests ├── __init__.py ├── benchmark │ └── __init__.py ├── tzif │ ├── Iceland │ ├── Asia │ │ ├── Amman │ │ └── NOT_A_TZIF │ ├── GMT-13.tzif │ ├── Amsterdam.tzif │ ├── Honolulu.tzif │ ├── Paris_v1.tzif │ ├── Sydney_widerange.tzif │ └── UTC.tzif ├── mypy.ini ├── common.py ├── test_year_month.py └── test_month_day.py ├── pysrc └── whenever │ ├── py.typed │ ├── _tz │ ├── __init__.py │ ├── common.py │ └── system.py │ ├── __init__.py │ └── _core.py ├── requirements ├── all.txt ├── docs.txt ├── lint.txt ├── typecheck.txt └── test.txt ├── pytest.ini ├── src ├── tz │ └── mod.rs ├── common │ ├── mod.rs │ ├── ambiguity.rs │ ├── rfc2822.rs │ └── round.rs ├── pymodule │ ├── mod.rs │ ├── tzconf.rs │ ├── utils.rs │ └── patch.rs ├── classes │ └── mod.rs ├── py │ ├── mod.rs │ ├── module.rs │ ├── dict.rs │ ├── exc.rs │ ├── refs.rs │ ├── tuple.rs │ ├── args.rs │ ├── num.rs │ ├── misc.rs │ ├── string.rs │ ├── types.rs │ ├── base.rs │ └── datetime.rs └── lib.rs ├── typesafety ├── test_date.yml ├── test_plain_datetime.yml ├── test_zoned_datetime.yml ├── test_offset_datetime.yml ├── test_deltas.yml └── test_instant.yml ├── MANIFEST.in ├── .github ├── dependabot.yml ├── PULL_REQUEST_TEMPLATE.md └── workflows │ ├── wheels.yml │ └── checks.yml ├── .coveragerc ├── benchmarks ├── python │ ├── test_plain_datetime.py │ ├── test_instant.py │ ├── test_zoned_datetime.py │ └── test_date.py ├── README.md ├── comparison │ ├── README.md │ ├── run_arrow.py │ ├── run_pendulum.py │ ├── run_whenever.py │ ├── run_stdlib.py │ ├── graph-vega-config.json │ ├── graph-dark.svg │ └── graph-light.svg └── rust │ └── main.rs ├── .flake8 ├── .cargo └── config.toml ├── .readthedocs.yml ├── _custom_pybuild └── backend.py ├── .gitignore ├── LICENSE ├── Cargo.toml ├── setup.py ├── scripts ├── smoketest_refcounting.py ├── smoketest_threading.py └── generate_docstrings.py ├── CONTRIBUTING.md ├── Makefile └── pyproject.toml /docs/_static/empty: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /pysrc/whenever/py.typed: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/benchmark/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /docs/changelog.rst: -------------------------------------------------------------------------------- 1 | .. include:: ../CHANGELOG.md 2 | :parser: myst_parser.sphinx_ 3 | -------------------------------------------------------------------------------- /requirements/all.txt: -------------------------------------------------------------------------------- 1 | -r test.txt 2 | -r lint.txt 3 | -r typecheck.txt 4 | -r docs.txt 5 | -------------------------------------------------------------------------------- /tests/tzif/Iceland: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ariebovenberg/whenever/HEAD/tests/tzif/Iceland -------------------------------------------------------------------------------- /docs/api-overview.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ariebovenberg/whenever/HEAD/docs/api-overview.png -------------------------------------------------------------------------------- /tests/tzif/Asia/Amman: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ariebovenberg/whenever/HEAD/tests/tzif/Asia/Amman -------------------------------------------------------------------------------- /tests/tzif/GMT-13.tzif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ariebovenberg/whenever/HEAD/tests/tzif/GMT-13.tzif -------------------------------------------------------------------------------- /pytest.ini: -------------------------------------------------------------------------------- 1 | [pytest] 2 | filterwarnings = 3 | error 4 | ignore::DeprecationWarning:dateutil.* 5 | -------------------------------------------------------------------------------- /tests/tzif/Amsterdam.tzif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ariebovenberg/whenever/HEAD/tests/tzif/Amsterdam.tzif -------------------------------------------------------------------------------- /tests/tzif/Asia/NOT_A_TZIF: -------------------------------------------------------------------------------- 1 | The presence of this file helps test the scanning for available tzif files. 2 | -------------------------------------------------------------------------------- /tests/tzif/Honolulu.tzif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ariebovenberg/whenever/HEAD/tests/tzif/Honolulu.tzif -------------------------------------------------------------------------------- /tests/tzif/Paris_v1.tzif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ariebovenberg/whenever/HEAD/tests/tzif/Paris_v1.tzif -------------------------------------------------------------------------------- /tests/tzif/Sydney_widerange.tzif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ariebovenberg/whenever/HEAD/tests/tzif/Sydney_widerange.tzif -------------------------------------------------------------------------------- /tests/tzif/UTC.tzif: -------------------------------------------------------------------------------- 1 | TZif2UTCTZif2UTC 2 | UTC0 3 | -------------------------------------------------------------------------------- /requirements/docs.txt: -------------------------------------------------------------------------------- 1 | sphinx>8.1,<8.2 2 | furo>=2025.9.25,<2025.13 3 | sphinx-copybutton~=0.5 4 | myst-parser>=3,<5 5 | enum-tools[sphinx]==0.13.0 6 | -------------------------------------------------------------------------------- /requirements/lint.txt: -------------------------------------------------------------------------------- 1 | black>=25,<26 2 | flake8>=6,<8 3 | isort>=6,<7 4 | slotscheck>=0.17,<0.20 5 | build 6 | twine>=6.2.0 7 | packaging>=24 8 | pyperf>2,<3 9 | -------------------------------------------------------------------------------- /requirements/typecheck.txt: -------------------------------------------------------------------------------- 1 | # TODO: Mypy 1.18 introduces some typecheck failures. Need to investigate and fix. 2 | mypy>=1,<1.18 3 | pytest-mypy-plugins>=3,<4 4 | -------------------------------------------------------------------------------- /src/tz/mod.rs: -------------------------------------------------------------------------------- 1 | //! Functionality for handling timezones and time zone information files (TZIF). 2 | pub mod posix; 3 | pub mod store; 4 | mod sync; 5 | pub mod tzif; 6 | -------------------------------------------------------------------------------- /pysrc/whenever/_tz/__init__.py: -------------------------------------------------------------------------------- 1 | from .common import Disambiguate, Fold, Gap, Unambiguous 2 | from .tzif import TimeZone 3 | 4 | __all__ = ["TimeZone", "Disambiguate", "Unambiguous", "Gap", "Fold"] 5 | -------------------------------------------------------------------------------- /requirements/test.txt: -------------------------------------------------------------------------------- 1 | pytest>=7,<9 2 | pytest-cov>=7,<8 3 | pytest-benchmark[histogram]>=4,<6 4 | pytest-order>=1.3,<2 5 | hypothesis>=6,<7 6 | time_machine>=2,<3; implementation_name == 'cpython' 7 | -------------------------------------------------------------------------------- /src/common/mod.rs: -------------------------------------------------------------------------------- 1 | //! Module with reusable components used elsewhere in the crate 2 | pub mod ambiguity; 3 | pub mod fmt; 4 | pub mod parse; 5 | pub mod rfc2822; 6 | pub mod round; 7 | pub mod scalar; 8 | -------------------------------------------------------------------------------- /src/pymodule/mod.rs: -------------------------------------------------------------------------------- 1 | //! Functionality for declaring the `whenever` module in Python and its 2 | //! associated methods. 3 | pub(crate) mod def; 4 | mod patch; 5 | mod tzconf; 6 | mod utils; 7 | 8 | pub(crate) use def::State; 9 | -------------------------------------------------------------------------------- /typesafety/test_date.yml: -------------------------------------------------------------------------------- 1 | - case: strict_equality 2 | regex: true 3 | main: | 4 | from whenever import Date 5 | 6 | Date(2020, 1, 1) == Date(2020, 1, 1) 7 | Date(2020, 1, 1) == "2020-01-01" # E: .*comparison.* 8 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include Cargo.toml 2 | include Cargo.lock 3 | include build.rs 4 | graft .cargo 5 | graft src 6 | graft pysrc 7 | graft _custom_pybuild 8 | graft tests 9 | global-exclude __pycache__ 10 | global-exclude *.py[co] 11 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: pip 4 | directory: "/" 5 | schedule: 6 | interval: daily 7 | time: "04:00" 8 | open-pull-requests-limit: 10 9 | commit-message: 10 | prefix: "🤖 " 11 | -------------------------------------------------------------------------------- /.coveragerc: -------------------------------------------------------------------------------- 1 | [coverage:run] 2 | branch=True 3 | [coverage:report] 4 | fail_under=100 5 | exclude_lines= 6 | pragma: no cover 7 | raise NotImplementedError 8 | def __repr__ 9 | @overload 10 | ^\s+\.\.\. 11 | if TYPE_CHECKING.*: 12 | -------------------------------------------------------------------------------- /benchmarks/python/test_plain_datetime.py: -------------------------------------------------------------------------------- 1 | from whenever import PlainDateTime 2 | 3 | 4 | def test_new(benchmark): 5 | benchmark(PlainDateTime, 2020, 3, 20, 12, 30, 45, nanosecond=450) 6 | 7 | 8 | def test_parse_canonical(benchmark): 9 | benchmark(PlainDateTime.parse_iso, "2023-09-03T23:01:00") 10 | -------------------------------------------------------------------------------- /.flake8: -------------------------------------------------------------------------------- 1 | [flake8] 2 | exclude = .git,__pycache__,docs/source/conf.py,old,build,dist,.tox 3 | extend-ignore = 4 | # let black handle line length 5 | E501 6 | # not black compatible (whitespace before `:`) 7 | E203 8 | per-file-ignores = 9 | __init__.py: F401,F403,F405 10 | _core.py: F401,F403,F405 11 | 12 | -------------------------------------------------------------------------------- /src/classes/mod.rs: -------------------------------------------------------------------------------- 1 | //! Each sub module corresponds to one of `whenever`s Python classes. 2 | pub mod date; 3 | pub mod date_delta; 4 | pub mod datetime_delta; 5 | pub mod instant; 6 | pub mod monthday; 7 | pub mod offset_datetime; 8 | pub mod plain_datetime; 9 | pub mod time; 10 | pub mod time_delta; 11 | pub mod yearmonth; 12 | pub mod zoned_datetime; 13 | -------------------------------------------------------------------------------- /docs/index.rst: -------------------------------------------------------------------------------- 1 | .. include:: ../README.md 2 | :parser: myst_parser.sphinx_ 3 | 4 | 5 | Contents 6 | ======== 7 | 8 | .. toctree:: 9 | :maxdepth: 1 10 | 11 | self 12 | examples.rst 13 | overview.rst 14 | deltas.rst 15 | faq.rst 16 | api.rst 17 | changelog.rst 18 | 19 | Indices and tables 20 | ================== 21 | 22 | * :ref:`genindex` 23 | * :ref:`modindex` 24 | -------------------------------------------------------------------------------- /.cargo/config.toml: -------------------------------------------------------------------------------- 1 | [build] 2 | rustflags = [] 3 | 4 | # see https://pyo3.rs/v0.21.2/building-and-distribution.html?highlight=macos#manual-builds 5 | [target.x86_64-apple-darwin] 6 | rustflags = [ 7 | "-C", "link-arg=-undefined", 8 | "-C", "link-arg=dynamic_lookup", 9 | ] 10 | 11 | [target.aarch64-apple-darwin] 12 | rustflags = [ 13 | "-C", "link-arg=-undefined", 14 | "-C", "link-arg=dynamic_lookup", 15 | ] 16 | -------------------------------------------------------------------------------- /benchmarks/README.md: -------------------------------------------------------------------------------- 1 | This folder contains code for benchmarking. 2 | 3 | There are three benchmarking directories: 4 | 5 | - ``comparison``: for comparing `whenever` with other datetime libraries. 6 | - ``python``: for benchmarking the library "end-to-end" in Python. 7 | - ``rust``: for benchmarking specific parts of the library in Rust. 8 | 9 | Benchmarking as part of CI is currently incomplete and work-in-progress. 10 | -------------------------------------------------------------------------------- /tests/mypy.ini: -------------------------------------------------------------------------------- 1 | # NOTE: This mypy configuration is used for the typesafety/ unit tests, 2 | # not for the main codebase. 3 | [mypy] 4 | strict = true 5 | 6 | # Somehow an error is triggered in here when running pytest-mypy-plugin 7 | # Thus, we ignore errors any errors there. 8 | [mypy-builtins.*] 9 | ignore_errors = true 10 | 11 | # ignore errors in the extension module 12 | [mypy-whenever.whenever] 13 | ignore_missing_imports = true 14 | -------------------------------------------------------------------------------- /benchmarks/python/test_instant.py: -------------------------------------------------------------------------------- 1 | from whenever import Instant 2 | 3 | 4 | def test_now(benchmark): 5 | benchmark(Instant.now) 6 | 7 | 8 | def test_change_tz(benchmark): 9 | dt = Instant.from_utc(2020, 3, 20, 12, 30, 45, nanosecond=450) 10 | benchmark(dt.to_tz, "America/New_York") 11 | 12 | 13 | def test_add_time(benchmark): 14 | dt = Instant.from_utc(2020, 3, 20, 12, 30, 45, nanosecond=450) 15 | benchmark(dt.add, hours=4, minutes=30) 16 | -------------------------------------------------------------------------------- /.readthedocs.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | 3 | formats: 4 | - htmlzip 5 | 6 | sphinx: 7 | builder: html 8 | configuration: docs/conf.py 9 | fail_on_warning: true 10 | 11 | build: 12 | os: ubuntu-22.04 13 | tools: 14 | python: "3.12" 15 | # rust shouldn't be needed as we disable building the extension 16 | # in the readthedocs configuration 17 | 18 | python: 19 | install: 20 | - requirements: requirements/docs.txt 21 | - method: pip 22 | path: . 23 | -------------------------------------------------------------------------------- /benchmarks/python/test_zoned_datetime.py: -------------------------------------------------------------------------------- 1 | from whenever import ZonedDateTime 2 | 3 | 4 | def test_new(benchmark): 5 | benchmark( 6 | ZonedDateTime, 7 | 2020, 8 | 3, 9 | 20, 10 | 12, 11 | 30, 12 | 45, 13 | nanosecond=450, 14 | tz="Europe/Amsterdam", 15 | ) 16 | 17 | 18 | def test_change_tz(benchmark): 19 | dt = ZonedDateTime( 20 | 2020, 3, 20, 12, 30, 45, nanosecond=450, tz="Europe/Amsterdam" 21 | ) 22 | benchmark(dt.to_tz, "America/New_York") 23 | -------------------------------------------------------------------------------- /.github/PULL_REQUEST_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | # Description 2 | 3 | 4 | 5 | # Checklist 6 | 7 | - [ ] Build runs successfully 8 | - [ ] Type stubs updated 9 | - [ ] Docs updated 10 | - [ ] If docstrings were affected, check if they appear correctly in the docs as well as autocomplete 11 | 12 | # Release checklist (maintainers only) 13 | 14 | - [ ] Version updated in ``pyproject.toml`` 15 | - [ ] Version updated in ``src/whenever/__init__.py`` 16 | - [ ] Version updated in changelog 17 | - [ ] Branch merged 18 | - [ ] Tag created and pushed 19 | - [ ] Confirm publish job runs successfully 20 | - [ ] Github release created 21 | -------------------------------------------------------------------------------- /docs/Makefile: -------------------------------------------------------------------------------- 1 | # Minimal makefile for Sphinx documentation 2 | # 3 | 4 | # You can set these variables from the command line. 5 | SPHINXOPTS = 6 | SPHINXBUILD = sphinx-build 7 | SPHINXPROJ = Quiz 8 | SOURCEDIR = . 9 | BUILDDIR = _build 10 | 11 | # Put it first so that "make" without argument is like "make help". 12 | help: 13 | @$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) 14 | 15 | .PHONY: help Makefile 16 | 17 | # Catch-all target: route all unknown targets to Sphinx using the new 18 | # "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS). 19 | %: Makefile 20 | @$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) -------------------------------------------------------------------------------- /_custom_pybuild/backend.py: -------------------------------------------------------------------------------- 1 | # See pyproject.toml for why this file exists. 2 | from setuptools import build_meta as _orig 3 | from setuptools.build_meta import * 4 | import platform 5 | import os 6 | 7 | 8 | if os.getenv("WHENEVER_NO_BUILD_RUST_EXT") or ( 9 | platform.python_implementation() in ("PyPy", "GraalVM") 10 | ): 11 | build_deps = [] 12 | else: 13 | build_deps = ["setuptools-rust"] 14 | 15 | 16 | def get_requires_for_build_wheel(config_settings=None): 17 | return build_deps 18 | 19 | 20 | def get_requires_for_build_sdist(config_settings=None): 21 | return build_deps 22 | 23 | 24 | def get_requires_for_build_editable(config_settings=None): 25 | return build_deps 26 | -------------------------------------------------------------------------------- /src/py/mod.rs: -------------------------------------------------------------------------------- 1 | //! Wrappers for interacting with Python's C API 2 | pub mod args; 3 | pub mod base; 4 | pub mod datetime; 5 | pub mod dict; 6 | pub mod exc; 7 | pub mod methods; 8 | pub mod misc; 9 | pub mod module; 10 | pub mod num; 11 | pub mod refs; 12 | pub mod string; 13 | pub mod tuple; 14 | pub mod types; 15 | 16 | pub(crate) use args::*; 17 | pub(crate) use base::*; 18 | pub(crate) use datetime::*; 19 | pub(crate) use dict::*; 20 | pub(crate) use exc::*; 21 | pub(crate) use methods::*; 22 | pub(crate) use misc::*; 23 | pub(crate) use module::*; 24 | pub(crate) use num::*; 25 | pub(crate) use refs::*; 26 | pub(crate) use string::*; 27 | pub(crate) use tuple::*; 28 | pub(crate) use types::*; 29 | -------------------------------------------------------------------------------- /src/lib.rs: -------------------------------------------------------------------------------- 1 | #![warn(unsafe_op_in_unsafe_fn)] 2 | use pyo3_ffi::*; 3 | 4 | // For benchmarking, all modules are public. The crate itself isn't distributed as a library, 5 | // but is only meant to be accessed through its Python API. 6 | pub mod common; 7 | #[rustfmt::skip] // this module is autogenerated. No need to format it. 8 | pub mod docstrings; 9 | pub mod classes; 10 | pub mod py; 11 | pub mod pymodule; 12 | pub mod tz; 13 | 14 | use crate::pymodule::def::MODULE_DEF; 15 | 16 | #[allow(clippy::missing_safety_doc)] 17 | #[allow(non_snake_case)] 18 | #[unsafe(no_mangle)] 19 | #[cold] 20 | pub unsafe extern "C" fn PyInit__whenever() -> *mut PyObject { 21 | unsafe { PyModuleDef_Init(&raw mut MODULE_DEF) } 22 | } 23 | -------------------------------------------------------------------------------- /typesafety/test_plain_datetime.yml: -------------------------------------------------------------------------------- 1 | - case: strict_equality 2 | regex: true 3 | main: | 4 | from whenever import PlainDateTime, Instant 5 | d = PlainDateTime(2020, 8, 9) 6 | d == 3 # E: .*comparison.* 7 | d == Instant.from_utc(2020, 8, 9) # E: .*comparison.* 8 | - case: addition 9 | regex: true 10 | main: | 11 | from whenever import PlainDateTime, months 12 | d = PlainDateTime(2020, 8, 9) 13 | reveal_type(d + months(4)) # N: .*whenever.PlainDateTime 14 | d + 12 # E: .*Unsupported 15 | 16 | - case: subtraction 17 | regex: true 18 | main: | 19 | from whenever import PlainDateTime, months 20 | d = PlainDateTime(2020, 8, 9) 21 | reveal_type(d - months(4)) # N: .*type is "whenever.PlainDateTime" 22 | -------------------------------------------------------------------------------- /benchmarks/comparison/README.md: -------------------------------------------------------------------------------- 1 | # Benchmark comparisons 2 | 3 | ## Running the benchmarks 4 | 5 | ```shell 6 | python benchmarks/comparison/run_whenever.py 7 | python benchmarks/comparison/run_stdlib.py 8 | python benchmarks/comparison/run_pendulum.py 9 | python benchmarks/comparison/run_arrow.py 10 | ``` 11 | 12 | Make sure that: 13 | - `whenever` is built in release mode 14 | - `time_machine` isn't installed. **Whenever** detects it and uses a slower code path if it is installed. 15 | 16 | ## Generating the graphs 17 | 18 | - Copy the `graph-vega-config.json` into https://vega.github.io/editor/#/ 19 | - Comment-out parts to get the light and dark versions 20 | - Export as svg 21 | - Add `font-weight="bold"` to the first appearance of "Whenever" 22 | - Set `width="500"` and `height="125"` 23 | 24 | ## Setup for the benchmark in the main README 25 | 26 | The benchmarking graph in the main README was generated on 27 | a 2021 M1 Pro Macbook, MacOS 15.6.1 on Python 3.13.3 (optimized build). 28 | -------------------------------------------------------------------------------- /typesafety/test_zoned_datetime.yml: -------------------------------------------------------------------------------- 1 | - case: strict_equality 2 | regex: true 3 | main: | 4 | from whenever import ZonedDateTime 5 | d = ZonedDateTime(2020, 8, 9, tz="Iceland") 6 | d == 3 # E: .*comparison.* 7 | - case: addition 8 | regex: true 9 | main: | 10 | from whenever import ZonedDateTime, TimeDelta 11 | d = ZonedDateTime(2020, 8, 9, tz="Iceland") 12 | reveal_type(d + TimeDelta(hours=4)) # N: .*whenever.ZonedDateTime 13 | d + 12 # E: .*Unsupported 14 | 15 | - case: subtraction 16 | regex: true 17 | main: | 18 | from whenever import ZonedDateTime, TimeDelta 19 | d = ZonedDateTime(2020, 8, 9, tz="Iceland") 20 | reveal_type(d - TimeDelta(hours=4)) # N: .*type is "whenever.ZonedDateTime" 21 | reveal_type(d - d) # N: .*type is "whenever.TimeDelta 22 | - case: replace 23 | regex: true 24 | main: | 25 | from whenever import ZonedDateTime 26 | d = ZonedDateTime(2020, 8, 9, tz="Iceland") 27 | d.replace(tzinfo=None) # E: .*Unexpected keyword.*tzinfo.* 28 | -------------------------------------------------------------------------------- /benchmarks/comparison/run_arrow.py: -------------------------------------------------------------------------------- 1 | # See Makefile for how to run this 2 | import pyperf 3 | 4 | runner = pyperf.Runner() 5 | 6 | 7 | runner.timeit( 8 | "various operations", 9 | "d = arrow.get('2020-04-05T22:04:00-04:00')" 10 | ".to('utc');" 11 | "d - arrow.utcnow();" 12 | "d.shift(hours=4, minutes=30)" 13 | ".to('Europe/Amsterdam')" 14 | ".isoformat()", 15 | setup="import arrow", 16 | ) 17 | 18 | # runner.timeit( 19 | # "new date", 20 | # "get(2020, 2, 29)", 21 | # "from arrow import get", 22 | # ) 23 | 24 | # runner.timeit( 25 | # "date add", 26 | # "d.shift(years=-4, months=59, weeks=-7, days=3)", 27 | # setup="from arrow import get; d = get(1987, 3, 31)", 28 | # ) 29 | 30 | # runner.timeit( 31 | # "parse date", 32 | # "get('2020-02-29')", 33 | # setup="from arrow import get", 34 | # ) 35 | 36 | # runner.timeit( 37 | # "change tz", 38 | # "dt.to('America/New_York')", 39 | # setup="import arrow; dt = arrow.get(2020, 3, 20, 12, 30, 45, 0, tz='Europe/Amsterdam'); ", 40 | # ) 41 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /target 2 | 3 | # Byte-compiled / optimized / DLL files 4 | __pycache__/ 5 | .pytest_cache/ 6 | *.py[cod] 7 | 8 | # C extensions 9 | *.so 10 | 11 | # Distribution / packaging 12 | .Python 13 | .venv/ 14 | env/ 15 | bin/ 16 | build/ 17 | develop-eggs/ 18 | dist/ 19 | eggs/ 20 | lib/ 21 | lib64/ 22 | parts/ 23 | sdist/ 24 | var/ 25 | include/ 26 | man/ 27 | venv/ 28 | *.egg-info/ 29 | .installed.cfg 30 | *.egg 31 | 32 | # Installer logs 33 | pip-log.txt 34 | pip-delete-this-directory.txt 35 | pip-selfcheck.json 36 | 37 | # Unit test / coverage reports 38 | htmlcov/ 39 | .tox/ 40 | .coverage 41 | .cache 42 | nosetests.xml 43 | coverage.xml 44 | 45 | # Translations 46 | *.mo 47 | 48 | # Mr Developer 49 | .mr.developer.cfg 50 | .project 51 | .pydevproject 52 | 53 | # Rope 54 | .ropeproject 55 | 56 | # Django stuff: 57 | *.log 58 | *.pot 59 | 60 | .DS_Store 61 | 62 | # Sphinx documentation 63 | docs/_build/ 64 | 65 | # PyCharm 66 | .idea/ 67 | 68 | # VSCode 69 | .vscode/ 70 | 71 | # Pyenv 72 | .python-version 73 | 74 | .hypothesis 75 | .benchmarks 76 | 77 | benchmarks/comparison/result_*.json 78 | -------------------------------------------------------------------------------- /typesafety/test_offset_datetime.yml: -------------------------------------------------------------------------------- 1 | # yaml-language-server: $schema=https://raw.githubusercontent.com/typeddjango/pytest-mypy-plugins/master/pytest_mypy_plugins/schema.json 2 | - case: strict_equality 3 | regex: true 4 | main: | 5 | from whenever import OffsetDateTime, Instant 6 | d = OffsetDateTime(2020, 8, 9, offset=1) 7 | d == OffsetDateTime(2023, 3, 10, offset=4) 8 | d == 3 # E: .*comparison.* 9 | d == Instant.from_utc(2020, 8, 8, 23) # E: .*comparison.* 10 | - case: subtraction 11 | regex: true 12 | main: | 13 | from whenever import OffsetDateTime, hours 14 | d = OffsetDateTime(2020, 8, 9, offset=3) 15 | reveal_type(d - d) # N: .*type is "whenever.TimeDelta 16 | d - hours(4) # E: .*operator.* 17 | - case: replace 18 | regex: true 19 | main: | 20 | from whenever import OffsetDateTime, hours 21 | d = OffsetDateTime(2020, 8, 9, offset=hours(1)) 22 | d.replace(tzinfo=None, ignore_dst=True) # E: .*Unexpected keyword.*tzinfo.* 23 | d.replace(year=None, ignore_dst=True) # E: .*incompatible type "None".* 24 | d.replace(offset=hours(2), ignore_dst=True) 25 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2022 - 2023 Arie Bovenberg 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /benchmarks/python/test_date.py: -------------------------------------------------------------------------------- 1 | import pickle 2 | import sys 3 | 4 | import pytest 5 | 6 | from whenever import _EXTENSION_LOADED, Date 7 | 8 | 9 | def test_hash(benchmark): 10 | d1 = Date(2020, 8, 24) 11 | benchmark(hash, d1) 12 | 13 | 14 | def test_new(benchmark): 15 | benchmark(Date, 2020, 8, 24) 16 | 17 | 18 | def test_format_iso(benchmark): 19 | d1 = Date(2020, 8, 24) 20 | benchmark(d1.format_iso) 21 | 22 | 23 | def test_parse_iso(benchmark): 24 | benchmark(Date.parse_iso, "2020-08-24") 25 | 26 | 27 | def test_add(benchmark): 28 | d1 = Date(2020, 8, 24) 29 | benchmark(d1.add, years=-4, months=59, weeks=-7, days=3) 30 | 31 | 32 | def test_diff(benchmark): 33 | d1 = Date(2020, 2, 29) 34 | d2 = Date(2025, 2, 28) 35 | benchmark(lambda: d1 - d2) 36 | 37 | 38 | def test_attributes(benchmark): 39 | d1 = Date(2020, 8, 24) 40 | benchmark(lambda: d1.year) 41 | 42 | 43 | def test_pickle(benchmark): 44 | d1 = Date(2020, 8, 24) 45 | benchmark(pickle.dumps, d1) 46 | 47 | 48 | @pytest.mark.skipif(not _EXTENSION_LOADED, reason="extension not loaded") 49 | def test_sizeof(): 50 | assert sys.getsizeof(Date(2020, 8, 24)) == 24 51 | -------------------------------------------------------------------------------- /Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "whenever" 3 | version = "0.1.0" # Dummy value. Isn't actually used in distribution of the Python package 4 | authors = [] 5 | description = "Rust extension module for whenever" 6 | edition = "2024" 7 | rust-version = "1.86" 8 | license = "MIT" 9 | readme = "README.md" 10 | keywords = [] 11 | include = [ 12 | "Cargo.toml", 13 | "pyproject.toml", 14 | "README.md", 15 | "src", 16 | "tests/*.py", 17 | "requirements/*.txt", 18 | ] 19 | 20 | [lib] 21 | name = "_whenever" 22 | crate-type = ["cdylib", "rlib"] 23 | 24 | [profile.release] 25 | lto = "fat" 26 | codegen-units = 1 27 | strip = true 28 | 29 | [dependencies] 30 | ahash = "^0.8.12" 31 | pyo3-ffi = { version = "^0.27.2", default-features = false, features = ["extension-module"]} 32 | 33 | [build-dependencies] 34 | pyo3-build-config = { version = "^0.27.2" } 35 | 36 | [dev-dependencies] 37 | criterion = "^0.8.1" 38 | walkdir = "^2.5.0" 39 | 40 | [[bench]] 41 | name = "criterion_benchmarks" 42 | harness = false 43 | path = "benchmarks/rust/main.rs" 44 | 45 | [lints.rust] 46 | unexpected_cfgs = { level = "warn", check-cfg = [ "cfg(Py_3_9)", "cfg(Py_3_10)", "cfg(Py_3_11)", "cfg(Py_3_12)", "cfg(Py_3_13)", "cfg(Py_GIL_DISABLED)"] } 47 | -------------------------------------------------------------------------------- /typesafety/test_deltas.yml: -------------------------------------------------------------------------------- 1 | - case: addition 2 | regex: true 3 | main: | 4 | from whenever import years, months, weeks, days, hours, minutes 5 | 6 | reveal_type(years(1)) # N: .*whenever.DateDelta 7 | reveal_type(months(1) + days(4)) # N: .*whenever.DateDelta 8 | reveal_type(weeks(1) + days(4)) # N: .*whenever.DateDelta 9 | reveal_type(hours(2) + minutes(40)) # N: .*whenever.TimeDelta 10 | reveal_type(minutes(1)) # N: .*whenever.TimeDelta 11 | reveal_type(years(2) + minutes(9)) # N: .*whenever.DateTimeDelta 12 | reveal_type(minutes(2) + years(9)) # N: .*whenever.DateTimeDelta 13 | - case: subtraction 14 | regex: true 15 | main: | 16 | from whenever import years, months, weeks, days, hours, minutes 17 | 18 | reveal_type(months(1) - days(4)) # N: .*whenever.DateDelta 19 | reveal_type(weeks(1) - days(4)) # N: .*whenever.DateDelta 20 | reveal_type(hours(2) - minutes(40)) # N: .*whenever.TimeDelta 21 | reveal_type(years(9) - minutes(1)) # N: .*whenever.DateTimeDelta 22 | reveal_type(minutes(9) - years(1)) # N: .*whenever.DateTimeDelta 23 | - case: strict_equality_prevents_mixing 24 | regex: true 25 | main: | 26 | from whenever import years, months, weeks, days, hours, minutes 27 | 28 | years(1) == months(12) 29 | hours(24) == days(1) # E: .*comparison.* 30 | hours(1) == minutes(60) 31 | -------------------------------------------------------------------------------- /benchmarks/comparison/run_pendulum.py: -------------------------------------------------------------------------------- 1 | # See Makefile for how to run this 2 | import pyperf 3 | 4 | runner = pyperf.Runner() 5 | 6 | runner.timeit( 7 | "various operations", 8 | "d = parse('2020-04-05T22:04:00-04:00')" 9 | ".in_tz('UTC');" 10 | "d.diff();" 11 | "d.add(hours=4, minutes=30)" 12 | ".in_tz('Europe/Amsterdam')" 13 | ".to_iso8601_string()", 14 | setup="from pendulum import parse, DateTime", 15 | ) 16 | 17 | # runner.timeit( 18 | # "new date", 19 | # "Date(2020, 2, 29)", 20 | # "from pendulum import Date", 21 | # ) 22 | 23 | 24 | # runner.timeit( 25 | # "date add", 26 | # "d.add(years=-4, months=59, weeks=-7, days=3)", 27 | # setup="from pendulum import Date; d = Date(1987, 3, 31)", 28 | # ) 29 | 30 | # runner.timeit( 31 | # "date diff", 32 | # "d1 - d2", 33 | # setup="from pendulum import Date; d1 = Date(2020, 2, 29); d2 = Date(2025, 2, 28)", 34 | # ) 35 | 36 | # runner.timeit( 37 | # "parse date", 38 | # "f('2020-02-29')", 39 | # setup="from pendulum import Date; f = Date.fromisoformat", 40 | # ) 41 | 42 | # runner.timeit( 43 | # "parse date delta", 44 | # "f('P5Y2M4D')", 45 | # setup="from pendulum import parse as f", 46 | # ) 47 | 48 | # runner.timeit( 49 | # "change tz", 50 | # "dt.in_tz('America/New_York')", 51 | # setup="from pendulum import datetime; dt = datetime(2020, 3, 20, 12, 30, 45, tz='Europe/Amsterdam')", 52 | # ) 53 | -------------------------------------------------------------------------------- /typesafety/test_instant.yml: -------------------------------------------------------------------------------- 1 | - case: ymd_arguments 2 | regex: true 3 | main: | 4 | from whenever import Instant 5 | d = Instant.from_utc(2020, 8, 9) 6 | d = Instant.from_utc(2020, 8, '15') # E: .*incompatible type "str".* "int" 7 | - case: strict_equality 8 | regex: true 9 | main: | 10 | from whenever import Instant, ZonedDateTime 11 | from typing import Union 12 | d = Instant.from_utc(2020, 8, 9) 13 | d == 3 # E: .*comparison.* 14 | e: Union[Instant, ZonedDateTime] = ZonedDateTime(2020, 1, 1, tz="Iceland") 15 | d == e 16 | - case: addition 17 | regex: true 18 | main: | 19 | from whenever import Instant, TimeDelta 20 | d = Instant.from_utc(2020, 8, 9) 21 | reveal_type(d + TimeDelta(hours=4)) # N: .*whenever.Instant 22 | d + 12 # E: .*Unsupported 23 | - case: subtraction 24 | regex: true 25 | main: | 26 | from whenever import Instant, TimeDelta 27 | d = Instant.from_utc(2020, 8, 9) 28 | reveal_type(d - TimeDelta(hours=4)) # N: .*type is "whenever.Instant" 29 | reveal_type(d - d) # N: .*type is "whenever.TimeDelta 30 | - case: to_fixed_offset 31 | regex: true 32 | main: | 33 | from whenever import Instant, hours 34 | d = Instant.from_utc(2020, 8, 9) 35 | d.to_fixed_offset() 36 | d.to_fixed_offset(hours(1)) 37 | d.to_fixed_offset(None) 38 | out: | 39 | main:5: E: .*overload 40 | main:5: N: .* 41 | main:5: N: .* 42 | main:5: N: .* 43 | -------------------------------------------------------------------------------- /docs/conf.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | import sphinx 3 | 4 | sphinx.SPHINXBUILD = True 5 | 6 | # -- Project information ----------------------------------------------------- 7 | import importlib.metadata 8 | 9 | metadata = importlib.metadata.metadata("whenever") 10 | 11 | project = metadata["Name"] 12 | version = metadata["Version"] 13 | release = metadata["Version"] 14 | 15 | 16 | # -- General configuration ------------------------------------------------ 17 | 18 | nitpicky = True 19 | nitpick_ignore = [ 20 | ("py:class", "whenever._pywhenever._T"), 21 | ] 22 | extensions = [ 23 | "sphinx.ext.autodoc", 24 | "sphinx.ext.intersphinx", 25 | "sphinx.ext.napoleon", 26 | "sphinx.ext.viewcode", 27 | "sphinx_copybutton", 28 | "enum_tools.autoenum", 29 | "myst_parser", 30 | ] 31 | templates_path = ["_templates"] 32 | source_suffix = { 33 | ".md": "markdown", 34 | ".rst": "restructuredtext", 35 | } 36 | html_static_path = ["_static"] 37 | 38 | master_doc = "index" 39 | exclude_patterns = ["_build", "Thumbs.db", ".DS_Store"] 40 | myst_heading_anchors = 2 41 | 42 | # -- Options for HTML output ---------------------------------------------- 43 | 44 | autodoc_member_order = "bysource" 45 | html_theme = "furo" 46 | highlight_language = "python3" 47 | pygments_style = "default" 48 | pygments_dark_style = "lightbulb" 49 | intersphinx_mapping = { 50 | "python": ("https://docs.python.org/3", None), 51 | } 52 | toc_object_entries_show_parents = "hide" 53 | -------------------------------------------------------------------------------- /benchmarks/comparison/run_whenever.py: -------------------------------------------------------------------------------- 1 | # See Makefile for how to run this 2 | import pyperf 3 | 4 | runner = pyperf.Runner() 5 | 6 | runner.timeit( 7 | "various operations", 8 | "d = OffsetDateTime.parse_iso('2020-04-05T22:04:00-04:00')" 9 | ".to_instant();" 10 | "d - Instant.now();" 11 | "d.add(hours=4, minutes=30)" 12 | ".to_tz('Europe/Amsterdam')" 13 | ".format_iso()", 14 | setup="from whenever import OffsetDateTime, Instant", 15 | ) 16 | 17 | # runner.timeit( 18 | # "new date", 19 | # "Date(2020, 2, 29)", 20 | # setup="from whenever import Date", 21 | # ) 22 | 23 | # runner.timeit( 24 | # "date add", 25 | # "d.add(years=-4, months=59, weeks=-7, days=3)", 26 | # setup="from whenever import Date; d = Date(1987, 3, 31)", 27 | # ) 28 | 29 | # runner.timeit( 30 | # "date diff", 31 | # "d1 - d2", 32 | # setup="from whenever import Date; d1 = Date(2020, 2, 29); d2 = Date(2025, 2, 28)", 33 | # ) 34 | 35 | # runner.timeit( 36 | # "parse date", 37 | # "f('2020-02-29')", 38 | # setup="from whenever import Date; f = Date.from_canonical_format", 39 | # ) 40 | 41 | # runner.timeit( 42 | # "parse date delta", 43 | # "f('P5Y2M4D')", 44 | # setup="from whenever import DateDelta; f = DateDelta.from_canonical_format", 45 | # ) 46 | 47 | # runner.timeit( 48 | # "change tz", 49 | # "dt.to_tz('America/New_York')", 50 | # setup="from whenever import ZonedDateTime; dt = ZonedDateTime(2020, 3, 20, 12, 30, 45, tz='Europe/Amsterdam')", 51 | # ) 52 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | import platform 4 | from setuptools import setup 5 | 6 | _SKIP_BUILD_SUGGESTION = """ 7 | ******************************************************************************* 8 | 9 | Building the Rust extension of the library `whenever` failed. See errors above. 10 | Set the `WHENEVER_NO_BUILD_RUST_EXT` environment variable to any value to skip 11 | building the Rust extension and use the (slower) Python version instead. 12 | 13 | ******************************************************************************* 14 | """ 15 | 16 | 17 | extra_setup_kwargs = {} 18 | 19 | if ( 20 | os.getenv("WHENEVER_NO_BUILD_RUST_EXT") 21 | or platform.python_implementation() in ("PyPy", "GraalVM") 22 | ): 23 | print("Skipping Whenever Rust extension build") 24 | else: 25 | from setuptools_rust import Binding, RustExtension, build_rust 26 | 27 | class BuildRust(build_rust): 28 | def run(self): 29 | try: 30 | build_rust.run(self) 31 | except Exception as e: 32 | print(_SKIP_BUILD_SUGGESTION) 33 | raise e 34 | 35 | extra_setup_kwargs.update( 36 | { 37 | "rust_extensions": [ 38 | RustExtension( 39 | "whenever._whenever", 40 | binding=Binding.NoBinding, 41 | ) 42 | ], 43 | "cmdclass": { 44 | "build_rust": BuildRust, 45 | }, 46 | } 47 | ) 48 | 49 | setup( 50 | **extra_setup_kwargs, 51 | ) 52 | -------------------------------------------------------------------------------- /benchmarks/comparison/run_stdlib.py: -------------------------------------------------------------------------------- 1 | # See Makefile for how to run this 2 | import pyperf 3 | 4 | runner = pyperf.Runner() 5 | 6 | runner.timeit( 7 | "various operations", 8 | "d = datetime.fromisoformat('2020-04-05T22:04:00-04:00')" 9 | ".astimezone(UTC);" 10 | "d - datetime.now(UTC);" 11 | "(d + timedelta(hours=4, minutes=30))" 12 | ".astimezone(ZoneInfo('Europe/Amsterdam'))" 13 | ".isoformat()", 14 | setup="from datetime import datetime, timedelta, UTC; from zoneinfo import ZoneInfo", 15 | ) 16 | 17 | # runner.timeit( 18 | # "new date", 19 | # "date(2020, 2, 29)", 20 | # "from datetime import date", 21 | # ) 22 | 23 | # runner.timeit( 24 | # "date add", 25 | # "d + relativedelta(years=-4, months=59, weeks=-7, days=3)", 26 | # setup="import datetime; from dateutil.relativedelta import relativedelta;" 27 | # "d = datetime.date(1987, 3, 31)", 28 | # ) 29 | 30 | # runner.timeit( 31 | # "date diff", 32 | # "relativedelta(d1, d2)", 33 | # setup="from datetime import date; from dateutil.relativedelta import relativedelta;" 34 | # "d1 = date(2020, 2, 29); d2 = date(2025, 2, 28)", 35 | # ) 36 | 37 | # runner.timeit( 38 | # "parse date", 39 | # "f('2020-02-29')", 40 | # setup="from datetime import date; f = date.fromisoformat", 41 | # ) 42 | 43 | # runner.timeit( 44 | # "change tz", 45 | # "dt.astimezone(ZoneInfo('America/New_York'))", 46 | # setup="from datetime import datetime; from zoneinfo import ZoneInfo; " 47 | # "dt = datetime(2020, 3, 20, 12, 30, 45, tzinfo=ZoneInfo('Europe/Amsterdam'))", 48 | # ) 49 | -------------------------------------------------------------------------------- /pysrc/whenever/_tz/common.py: -------------------------------------------------------------------------------- 1 | from typing import Literal, Union 2 | 3 | Disambiguate = Literal["compatible", "earlier", "later", "raise"] 4 | 5 | 6 | class Unambiguous: 7 | offset: int 8 | 9 | def __init__(self, offset: int): 10 | self.offset = offset 11 | 12 | def __eq__(self, other: object) -> bool: 13 | if isinstance(other, Unambiguous): 14 | return self.offset == other.offset 15 | return False # pragma: no cover 16 | 17 | def __repr__(self) -> str: 18 | return f"Unambiguous({self.offset})" 19 | 20 | 21 | class Gap: 22 | before: int 23 | after: int 24 | 25 | def __init__(self, before: int, after: int): 26 | self.before = before 27 | self.after = after 28 | 29 | def __eq__(self, other: object) -> bool: 30 | if isinstance(other, Gap): 31 | return self.before == other.before and self.after == other.after 32 | return False # pragma: no cover 33 | 34 | def __repr__(self) -> str: 35 | return f"Gap({self.before}, {self.after})" 36 | 37 | 38 | class Fold: 39 | before: int 40 | after: int 41 | 42 | def __init__(self, before: int, after: int): 43 | self.before = before 44 | self.after = after 45 | 46 | def __eq__(self, other: object) -> bool: 47 | if isinstance(other, Fold): 48 | return self.before == other.before and self.after == other.after 49 | return False # pragma: no cover 50 | 51 | def __repr__(self) -> str: 52 | return f"Fold({self.before}, {self.after})" 53 | 54 | 55 | Ambiguity = Union[Unambiguous, Gap, Fold] 56 | -------------------------------------------------------------------------------- /src/py/module.rs: -------------------------------------------------------------------------------- 1 | //! Functions for working with the module object. 2 | 3 | use super::{base::*, exc::*, types::*}; 4 | use crate::pymodule::State; 5 | use core::mem::MaybeUninit; 6 | use pyo3_ffi::*; 7 | 8 | #[derive(Debug, Clone, Copy)] 9 | pub(crate) struct PyModule { 10 | obj: PyObj, 11 | } 12 | 13 | impl PyBase for PyModule { 14 | fn as_py_obj(&self) -> PyObj { 15 | self.obj 16 | } 17 | } 18 | 19 | impl FromPy for PyModule { 20 | unsafe fn from_ptr_unchecked(ptr: *mut PyObject) -> Self { 21 | Self { 22 | obj: unsafe { PyObj::from_ptr_unchecked(ptr) }, 23 | } 24 | } 25 | } 26 | 27 | impl PyStaticType for PyModule { 28 | fn isinstance_exact(obj: impl PyBase) -> bool { 29 | unsafe { PyModule_CheckExact(obj.as_ptr()) != 0 } 30 | } 31 | 32 | fn isinstance(obj: impl PyBase) -> bool { 33 | unsafe { PyModule_Check(obj.as_ptr()) != 0 } 34 | } 35 | } 36 | 37 | impl PyModule { 38 | #[allow(clippy::mut_from_ref)] 39 | pub(crate) fn state<'a>(&self) -> &'a mut MaybeUninit> { 40 | // SAFETY: calling CPython API with valid arguments 41 | unsafe { 42 | PyModule_GetState(self.as_ptr()) 43 | .cast::>>() 44 | .as_mut() 45 | } 46 | .unwrap() 47 | } 48 | 49 | pub(crate) fn add_type(&self, cls: PyType) -> PyResult<()> { 50 | // SAFETY: calling CPython API with valid arguments 51 | if unsafe { PyModule_AddType(self.as_ptr(), cls.as_ptr().cast()) } == 0 { 52 | Ok(()) 53 | } else { 54 | Err(PyErrMarker()) 55 | } 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /scripts/smoketest_refcounting.py: -------------------------------------------------------------------------------- 1 | """ 2 | Stress tests for refcounting in the timezone cache (Rust implementation). 3 | 4 | This test can surface refcounting issues when many timezones are loaded and unloaded. 5 | """ 6 | 7 | import os 8 | 9 | from whenever import PlainDateTime, reset_system_tz 10 | 11 | f = PlainDateTime(2023, 10, 1, 12, 0, 0) 12 | 13 | 14 | def main(): 15 | f.assume_tz("Iceland") 16 | f.assume_tz("Iceland") 17 | f.assume_tz("Iceland") 18 | f.assume_tz("Europe/London") 19 | f.assume_tz("Europe/London") 20 | d = f.assume_tz("Europe/London") # noqa 21 | f.assume_tz("Europe/London") 22 | f.assume_tz("Asia/Tokyo") 23 | f.assume_tz("Asia/Tokyo") 24 | f.assume_tz("America/New_York") 25 | f.assume_tz("America/Los_Angeles") 26 | f.assume_tz("America/Chicago") 27 | f.assume_tz("America/Denver") 28 | f.assume_tz("America/Argentina/Buenos_Aires") 29 | f.assume_tz("America/Sao_Paulo") 30 | f.assume_tz("Asia/Kolkata") 31 | f.assume_tz("Asia/Shanghai") 32 | f.assume_tz("Australia/Sydney") 33 | f.assume_system_tz() 34 | f.assume_system_tz() 35 | f.assume_system_tz() 36 | f.assume_tz("Europe/Amsterdam") 37 | f.assume_tz("Europe/Amsterdam") 38 | f.assume_tz("Europe/Amsterdam") 39 | 40 | reset_system_tz() 41 | f.assume_system_tz() 42 | os.environ["TZ"] = "America/New_York" 43 | f.assume_system_tz() 44 | f.assume_tz("Europe/Amsterdam") 45 | 46 | # A posix timezone 47 | os.environ["TZ"] = "IST-5:30" 48 | f.assume_system_tz() 49 | 50 | # A path timezone 51 | path = os.environ["TZ"] = "/usr/share/zoneinfo/Asia/Tokyo" 52 | if os.path.exists(path): 53 | reset_system_tz() 54 | else: 55 | print("Path timezone not found, skipping that part of the test") 56 | 57 | 58 | if __name__ == "__main__": 59 | main() 60 | -------------------------------------------------------------------------------- /pysrc/whenever/__init__.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from ._core import * 4 | from ._core import ( # The unpickle functions must be findable at module-level 5 | _EXTENSION_LOADED, 6 | _unpkl_date, 7 | _unpkl_ddelta, 8 | _unpkl_dtdelta, 9 | _unpkl_inst, 10 | _unpkl_local, 11 | _unpkl_md, 12 | _unpkl_offset, 13 | _unpkl_tdelta, 14 | _unpkl_time, 15 | _unpkl_utc, 16 | _unpkl_ym, 17 | _unpkl_zoned, 18 | ) 19 | from ._utils import * 20 | 21 | # These imports are only needed for the doc generation, which only 22 | # runs in pure Python mode. 23 | if not _EXTENSION_LOADED: # pragma: no cover 24 | from ._pywhenever import ( 25 | __all__, 26 | _BasicConversions, 27 | _ExactAndLocalTime, 28 | _ExactTime, 29 | _LocalTime, 30 | ) 31 | 32 | 33 | # Yes, we could get the version with importlib.metadata, 34 | # but we try to keep our import time as low as possible. 35 | __version__ = "0.9.4" 36 | 37 | reset_tzpath() # populate the tzpath once at startup 38 | 39 | 40 | # Handle deprecated names 41 | def __getattr__(name: str) -> object: 42 | import warnings 43 | 44 | # This ensures we get the most up-to-date TZPATH. 45 | if name == "TZPATH": 46 | from ._utils import TZPATH 47 | 48 | return TZPATH 49 | elif name in ("NaiveDateTime", "LocalDateTime"): 50 | warnings.warn( 51 | f"whenever.{name} has been renamed to PlainDateTime.", 52 | DeprecationWarning, 53 | ) 54 | return PlainDateTime 55 | elif name == "SystemDateTime": # pragma: no cover 56 | raise ImportError( 57 | "whenever.SystemDateTime has been removed. See the changelog for " 58 | "migration instructions.", 59 | ) 60 | 61 | raise AttributeError(f"module '{__name__}' has no attribute '{name}'") 62 | -------------------------------------------------------------------------------- /src/pymodule/tzconf.rs: -------------------------------------------------------------------------------- 1 | //! Functions in the `whenever` module that manage the TZ cache and search path 2 | use crate::{py::*, pymodule::State}; 3 | use std::path::PathBuf; 4 | 5 | pub(crate) fn _set_tzpath(state: &mut State, to: PyObj) -> PyReturn { 6 | let Some(py_tuple) = to.cast_exact::() else { 7 | raise_type_err("Argument must be a tuple")? 8 | }; 9 | 10 | let mut result = Vec::with_capacity(py_tuple.len() as _); 11 | 12 | for path in py_tuple.iter() { 13 | result.push(PathBuf::from( 14 | path.cast_allow_subclass::() 15 | .ok_or_type_err("Path must be a string")? 16 | .as_str()?, 17 | )) 18 | } 19 | state.tz_store.set_paths(result); 20 | Ok(none()) 21 | } 22 | 23 | pub(crate) fn _clear_tz_cache(state: &mut State) -> PyReturn { 24 | state.tz_store.clear_all(); 25 | Ok(none()) 26 | } 27 | 28 | pub(crate) fn _clear_tz_cache_by_keys(state: &mut State, keys_obj: PyObj) -> PyReturn { 29 | let Some(py_tuple) = keys_obj.cast_exact::() else { 30 | raise_type_err("Argument must be a tuple")? 31 | }; 32 | let mut keys = Vec::with_capacity(py_tuple.len() as _); 33 | for k in py_tuple.iter() { 34 | keys.push( 35 | k.cast_allow_subclass::() 36 | .ok_or_type_err("Key must be a string")? 37 | .as_str()? 38 | // FUTURE: We should be able to use string slices here, but 39 | // making this work with lifetimes requires a bit of work. 40 | // Since this isn't performance critical, we leave it for now. 41 | .to_string(), 42 | ); 43 | } 44 | state.tz_store.clear_only(&keys); 45 | Ok(none()) 46 | } 47 | 48 | pub(crate) fn reset_system_tz(state: &mut State) -> PyReturn { 49 | state.tz_store.reset_system_tz()?; 50 | Ok(none()) 51 | } 52 | -------------------------------------------------------------------------------- /pysrc/whenever/_core.py: -------------------------------------------------------------------------------- 1 | """This makes the core API importable (internally) whether or not the Rust 2 | extension is available.""" 3 | 4 | try: # pragma: no cover 5 | from ._whenever import * 6 | from ._whenever import ( 7 | _clear_tz_cache as _clear_tz_cache, 8 | _clear_tz_cache_by_keys as _clear_tz_cache_by_keys, 9 | _patch_time_frozen as _patch_time_frozen, 10 | _patch_time_keep_ticking as _patch_time_keep_ticking, 11 | _set_tzpath as _set_tzpath, 12 | _unpatch_time as _unpatch_time, 13 | _unpkl_date, 14 | _unpkl_ddelta, 15 | _unpkl_dtdelta, 16 | _unpkl_inst, 17 | _unpkl_local, 18 | _unpkl_md, 19 | _unpkl_offset, 20 | _unpkl_tdelta, 21 | _unpkl_time, 22 | _unpkl_utc, 23 | _unpkl_ym, 24 | _unpkl_zoned, 25 | ) 26 | 27 | _EXTENSION_LOADED = True 28 | 29 | except ModuleNotFoundError as e: 30 | # Ensure we don't silence other ModuleNotFoundErrors! 31 | if e.name != "whenever._whenever": # pragma: no cover 32 | raise e 33 | from ._pywhenever import * 34 | from ._pywhenever import ( 35 | _clear_tz_cache, 36 | _clear_tz_cache_by_keys, 37 | _patch_time_frozen, 38 | _patch_time_keep_ticking, 39 | _set_tzpath, 40 | _unpatch_time, 41 | _unpkl_date, 42 | _unpkl_ddelta, 43 | _unpkl_dtdelta, 44 | _unpkl_inst, 45 | _unpkl_local, 46 | _unpkl_md, 47 | _unpkl_offset, 48 | _unpkl_tdelta, 49 | _unpkl_time, 50 | _unpkl_utc, 51 | _unpkl_ym, 52 | _unpkl_zoned, 53 | ) 54 | 55 | _EXTENSION_LOADED = False 56 | 57 | MONDAY = Weekday.MONDAY 58 | TUESDAY = Weekday.TUESDAY 59 | WEDNESDAY = Weekday.WEDNESDAY 60 | THURSDAY = Weekday.THURSDAY 61 | FRIDAY = Weekday.FRIDAY 62 | SATURDAY = Weekday.SATURDAY 63 | SUNDAY = Weekday.SUNDAY 64 | -------------------------------------------------------------------------------- /benchmarks/rust/main.rs: -------------------------------------------------------------------------------- 1 | // FUTURE: 2 | // - find a better way to access function other than making them public 3 | // - find a better way to organize these benchmarks 4 | use criterion::{Criterion, criterion_group, criterion_main}; 5 | 6 | use _whenever::classes::date::Date; 7 | use _whenever::classes::plain_datetime::DateTime; 8 | use _whenever::common::scalar::{EpochSecs, UnixDays}; 9 | use _whenever::common::scalar::{Month, Year}; 10 | use _whenever::tz::posix::TzStr; 11 | use _whenever::tz::tzif::TimeZone; 12 | use std::hint::black_box; 13 | 14 | pub fn date_from_unix_days(c: &mut Criterion) { 15 | c.bench_function("unix day to date", |b| { 16 | let d = UnixDays::new_unchecked(30179); 17 | b.iter(|| black_box(d).date()) 18 | }); 19 | } 20 | 21 | pub fn parse_plain_datetime(c: &mut Criterion) { 22 | c.bench_function("Parse plain datetime", |b| { 23 | b.iter(|| { 24 | DateTime::parse(black_box(b"2023-03-02 02:09:09")).unwrap(); 25 | }) 26 | }); 27 | } 28 | 29 | pub fn parse_posix_tz(c: &mut Criterion) { 30 | c.bench_function("Parse POSIX TZ", |b| { 31 | b.iter(|| TzStr::parse(black_box(b"PST8PDT,M3.2.0,M11.1.0")).unwrap()) 32 | }); 33 | } 34 | 35 | pub fn offset_for_local_time(c: &mut Criterion) { 36 | const TZ_AMS: &[u8] = include_bytes!("../../tests/tzif/Amsterdam.tzif"); 37 | let tzif = TimeZone::parse_tzif(TZ_AMS, None).unwrap(); 38 | 39 | c.bench_function("offset for local", |b| { 40 | let t = EpochSecs::new(1719946800).unwrap(); 41 | b.iter(|| tzif.ambiguity_for_local(black_box(t))) 42 | }); 43 | } 44 | 45 | pub fn tomorrow(c: &mut Criterion) { 46 | c.bench_function("tomorrow for date", |b| { 47 | let date = black_box(Date::new(Year::new_unchecked(2023), Month::March, 2).unwrap()); 48 | b.iter(|| { 49 | date.tomorrow().unwrap(); 50 | }) 51 | }); 52 | } 53 | 54 | criterion_group!( 55 | benches, 56 | date_from_unix_days, 57 | parse_plain_datetime, 58 | parse_posix_tz, 59 | offset_for_local_time, 60 | tomorrow, 61 | ); 62 | criterion_main!(benches); 63 | -------------------------------------------------------------------------------- /src/py/dict.rs: -------------------------------------------------------------------------------- 1 | //! Functions for manipulating Python dictionaries. 2 | use super::{base::*, exc::*}; 3 | use core::{ffi::CStr, ptr::null_mut as NULL}; 4 | use pyo3_ffi::*; 5 | 6 | #[derive(Debug, Clone, Copy, PartialEq, Eq)] 7 | pub(crate) struct PyDict { 8 | obj: PyObj, 9 | } 10 | 11 | impl PyBase for PyDict { 12 | fn as_py_obj(&self) -> PyObj { 13 | self.obj 14 | } 15 | } 16 | 17 | impl FromPy for PyDict { 18 | unsafe fn from_ptr_unchecked(ptr: *mut PyObject) -> Self { 19 | Self { 20 | obj: unsafe { PyObj::from_ptr_unchecked(ptr) }, 21 | } 22 | } 23 | } 24 | 25 | impl PyStaticType for PyDict { 26 | fn isinstance_exact(obj: impl PyBase) -> bool { 27 | unsafe { PyDict_CheckExact(obj.as_ptr()) != 0 } 28 | } 29 | 30 | fn isinstance(obj: impl PyBase) -> bool { 31 | unsafe { PyDict_Check(obj.as_ptr()) != 0 } 32 | } 33 | } 34 | 35 | impl PyDict { 36 | pub(crate) fn set_item_str(&self, key: &CStr, value: PyObj) -> PyResult<()> { 37 | if unsafe { PyDict_SetItemString(self.obj.as_ptr(), key.as_ptr(), value.as_ptr()) } == -1 { 38 | return Err(PyErrMarker()); 39 | } 40 | Ok(()) 41 | } 42 | 43 | pub(crate) fn len(&self) -> Py_ssize_t { 44 | unsafe { PyDict_Size(self.obj.as_ptr()) } 45 | } 46 | 47 | pub(crate) fn iteritems(&self) -> PyDictIterItems { 48 | PyDictIterItems { 49 | obj: self.obj.as_ptr(), 50 | pos: 0, 51 | } 52 | } 53 | } 54 | 55 | pub(crate) struct PyDictIterItems { 56 | obj: *mut PyObject, 57 | pos: Py_ssize_t, 58 | } 59 | 60 | impl Iterator for PyDictIterItems { 61 | type Item = (PyObj, PyObj); 62 | 63 | fn next(&mut self) -> Option { 64 | let mut key = NULL(); 65 | let mut value = NULL(); 66 | (unsafe { PyDict_Next(self.obj, &mut self.pos, &mut key, &mut value) } != 0).then( 67 | || unsafe { 68 | ( 69 | PyObj::from_ptr_unchecked(key), 70 | PyObj::from_ptr_unchecked(value), 71 | ) 72 | }, 73 | ) 74 | } 75 | } 76 | -------------------------------------------------------------------------------- /src/common/ambiguity.rs: -------------------------------------------------------------------------------- 1 | //! Functionality for handling ambiguous datetime values. 2 | use crate::{common::scalar::Offset, py::*}; 3 | 4 | #[derive(Debug, Copy, Clone, PartialEq, Eq)] 5 | pub(crate) enum Disambiguate { 6 | Compatible, 7 | Earlier, 8 | Later, 9 | Raise, 10 | } 11 | 12 | #[derive(Debug, Copy, Clone, PartialEq, Eq)] 13 | pub enum Ambiguity { 14 | Unambiguous(Offset), 15 | Gap(Offset, Offset), // (earlier, later) occurrence, (a > b) 16 | Fold(Offset, Offset), // (later, earlier) occurrence, (a > b) 17 | } 18 | 19 | impl Disambiguate { 20 | pub(crate) fn from_only_kwarg( 21 | kwargs: &mut IterKwargs, 22 | str_disambiguate: PyObj, 23 | fname: &str, 24 | str_compatible: PyObj, 25 | str_raise: PyObj, 26 | str_earlier: PyObj, 27 | str_later: PyObj, 28 | ) -> PyResult> { 29 | match kwargs.next() { 30 | Some((name, value)) => { 31 | if kwargs.len() == 1 { 32 | if name.py_eq(str_disambiguate)? { 33 | Self::from_py(value, str_compatible, str_raise, str_earlier, str_later) 34 | .map(Some) 35 | } else { 36 | raise_type_err(format!( 37 | "{fname}() got an unexpected keyword argument {name}" 38 | )) 39 | } 40 | } else { 41 | raise_type_err(format!( 42 | "{}() takes at most 1 keyword argument, got {}", 43 | fname, 44 | kwargs.len() 45 | )) 46 | } 47 | } 48 | None => Ok(None), 49 | } 50 | } 51 | 52 | pub(crate) fn from_py( 53 | obj: PyObj, 54 | str_compatible: PyObj, 55 | str_raise: PyObj, 56 | str_earlier: PyObj, 57 | str_later: PyObj, 58 | ) -> PyResult { 59 | match_interned_str("disambiguate", obj, |v, eq| { 60 | Some(if eq(v, str_compatible) { 61 | Disambiguate::Compatible 62 | } else if eq(v, str_raise) { 63 | Disambiguate::Raise 64 | } else if eq(v, str_earlier) { 65 | Disambiguate::Earlier 66 | } else if eq(v, str_later) { 67 | Disambiguate::Later 68 | } else { 69 | None? 70 | }) 71 | }) 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing 2 | 3 | ## Before you start 4 | 5 | Contributions are welcome, but be sure to read the guidelines below first. 6 | 7 | - Non-trivial changes should be discussed in an issue first. 8 | This is to avoid wasted effort if the change isn't a good fit for the project. 9 | 10 | - Before picking up an issue, please comment on it to let others know you're working on it. 11 | This will help avoid duplicated effort. 12 | 13 | ## Setting up a development environment 14 | 15 | An example of setting up things up on a Unix-like system: 16 | 17 | ```bash 18 | # install the dependencies 19 | make init 20 | 21 | # build the rust extension 22 | make build 23 | 24 | # clear the build artifacts (useful if you want to test the pure Python version) 25 | make clean 26 | 27 | make test # run the tests (Python and Rust) 28 | make format # apply autoformatting 29 | make ci-lint # various static checks 30 | make typecheck # run mypy and typing tests 31 | ``` 32 | 33 | ## Maintainer's notes 34 | 35 | Below are some points to keep in mind when making changes to the codebase: 36 | 37 | - I purposefully opted for ``pyo3_ffi`` over ``pyo3``. There are the main reasons: 38 | 39 | 1. The higher-level binding library PyO3 has a small additional overhead for function calls, 40 | which can be significant for small functions. Whenever has a lot of small functions. 41 | Only with ``pyo3_ffi`` can these functions be on par (or faster) than the standard library. 42 | The overhead has decreased in recent versions of PyO3, but it's still there. 43 | 2. I was eager to learn to use the bare C API of Python, in order to better 44 | understand how Python extension modules and PyO3 work under the hood. 45 | 3. ``whenever``'s use case is quite simple: it only contains immutable data types 46 | with small methods. It doesn't need the full power of PyO3. 47 | 48 | Additional advantages of ``pyo3_ffi`` are: 49 | 50 | - Its API is more stable than PyO3's, which is still evolving. 51 | - It allows support for per-interpreter GIL, and free-threaded Python, 52 | which are not yet (fully) supported by PyO3. 53 | 54 | - The tests and documentation of the Rust code are sparse. This is because 55 | it has no public interface and is only used through its Python bindings. 56 | You can find comprehensive tests and documentation in the Python codebase. 57 | - To keep import time fast, some "obvious" Python modules (pathlib, re, dataclasses, 58 | importlib.resources) are not used, or imported lazily. 59 | -------------------------------------------------------------------------------- /scripts/smoketest_threading.py: -------------------------------------------------------------------------------- 1 | """ 2 | Stress tests for thread-safety of the timezone cache. 3 | 4 | Note this isn't a unit test, because it relies on a clean cache 5 | """ 6 | 7 | import sys 8 | import time 9 | from os import environ 10 | from threading import Thread 11 | 12 | from whenever import PlainDateTime, reset_system_tz 13 | 14 | if not hasattr(sys, "_is_gil_enabled") or sys._is_gil_enabled(): 15 | # Running with GIL enabled can still be useful to compare performance, 16 | # but be sure to warn that threading hasn't been stress tested. 17 | print("WARNING: Running with GIL enabled. Threading not stress tested.") 18 | 19 | 20 | PLAIN_DT = PlainDateTime(2024, 6, 15, 12, 0) 21 | NUM_THREADS = 16 22 | NUM_ITERATIONS = 500 23 | TIMEZONE_SAMPLE = [ 24 | "UTC", 25 | "America/Guyana", 26 | "Etc/GMT-11", 27 | "Europe/Vienna", 28 | "America/Rainy_River", 29 | "Asia/Ulaanbaatar", 30 | "US/Alaska", 31 | "America/Rankin_Inlet", 32 | "Arctic/Longyearbyen", 33 | "Pacific/Bougainville", 34 | "Africa/Monrovia", 35 | "Europe/Copenhagen", 36 | "America/Hermosillo", 37 | "Africa/Brazzaville", 38 | "Asia/Tashkent", 39 | "Pacific/Saipan", 40 | "Europe/Tallinn", 41 | "Europe/Uzhgorod", 42 | "Africa/Nairobi", 43 | "America/Argentina/Ushuaia", 44 | "Brazil/Acre", 45 | ] 46 | assert ( 47 | len(TIMEZONE_SAMPLE) % NUM_THREADS 48 | ), "Timezone sample should not be evenly divisible by number of threads" 49 | TZS = TIMEZONE_SAMPLE * (NUM_THREADS * NUM_ITERATIONS) 50 | 51 | 52 | def touch_timezones(tzs): 53 | """A minimal function that triggers a timezone lookup""" 54 | for tz in tzs: 55 | zdt = PLAIN_DT.assume_tz(tz) 56 | del zdt 57 | 58 | 59 | def set_system_tz(tzs): 60 | """A function that sets the timezone to system timezone""" 61 | for tz in tzs: 62 | environ["TZ"] = tz 63 | reset_system_tz() 64 | zdt = PLAIN_DT.assume_system_tz() 65 | del zdt 66 | 67 | 68 | def main(func): 69 | print(f"Starting test: {func.__name__}") 70 | threads = [] 71 | 72 | start_time = time.time() 73 | 74 | for n in range(NUM_THREADS): 75 | thread = Thread(target=func, args=(TZS[n::NUM_THREADS],)) 76 | threads.append(thread) 77 | thread.start() 78 | 79 | for thread in threads: 80 | thread.join() 81 | 82 | end_time = time.time() 83 | print(f"Execution time: {end_time - start_time:.2f} seconds") 84 | 85 | 86 | if __name__ == "__main__": 87 | main(touch_timezones) 88 | main(set_system_tz) 89 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | .PHONY: init 2 | init: 3 | pip install -U setuptools-rust setuptools build pyperf 4 | pip install -U -r requirements/all.txt 5 | pip install -e . 6 | 7 | .PHONY: typecheck 8 | typecheck: 9 | mypy pysrc/ tests/ 10 | pytest --mypy-ini-file=tests/mypy.ini typesafety/ 11 | 12 | .PHONY: format 13 | format: 14 | black pysrc/ tests/ scripts/ 15 | isort pysrc/ tests/ scripts/ 16 | cargo fmt 17 | 18 | .PHONY: docs 19 | docs: 20 | rm -f pysrc/whenever/*.so # Presence of the rust extension breaks sphinx (TODO: a better workaround) 21 | @touch docs/api.rst # force rebuild of API docs: code changes aren't detected 22 | make -C docs/ html 23 | 24 | .PHONY: check-readme 25 | check-readme: 26 | python -m build --sdist 27 | twine check dist/* 28 | 29 | .PHONY: test-py 30 | test-py: 31 | RUST_BACKTRACE=1 pytest -s tests/ 32 | 33 | 34 | .PHONY: test-rs 35 | test-rs: 36 | RUST_BACKTRACE=1 cargo test 37 | 38 | .PHONY: test 39 | test: test-py test-rs 40 | 41 | .PHONY: ci-lint 42 | ci-lint: check-readme 43 | flake8 pysrc/ tests/ scripts/ 44 | black --check pysrc/ tests/ scripts/ 45 | isort --check pysrc/ tests/ scripts/ 46 | cargo fmt -- --check 47 | env PYTHONPATH=pysrc/ slotscheck pysrc 48 | cargo clippy --all-targets --all-features -- -D warnings 49 | 50 | .PHONY: clean 51 | clean: 52 | python setup.py clean --all 53 | rm -rf build/ dist/ pysrc/**/*.so pysrc/**/__pycache__ *.egg-info **/*.egg-info \ 54 | docs/_build/ htmlcov/ .mypy_cache/ .pytest_cache/ target/ 55 | 56 | 57 | .PHONY: build 58 | build: 59 | python setup.py build_rust --inplace 60 | 61 | .PHONY: build-release 62 | build-release: 63 | python setup.py build_rust --inplace --release 64 | 65 | .PHONY: bench 66 | bench: build-release 67 | pytest -s benchmarks/ \ 68 | --benchmark-group-by=group \ 69 | --benchmark-columns=median,stddev \ 70 | --benchmark-autosave \ 71 | --benchmark-group-by=fullname \ 72 | 73 | .PHONY: bench-compare 74 | bench-compare: build-release 75 | rm -f benchmarks/comparison/result_*.json 76 | python benchmarks/comparison/run_stdlib_dateutil.py --fast -o \ 77 | benchmarks/comparison/result_stdlib_dateutil.json 78 | python benchmarks/comparison/run_pendulum.py --fast -o \ 79 | benchmarks/comparison/result_pendulum.json 80 | python benchmarks/comparison/run_arrow.py --fast -o \ 81 | benchmarks/comparison/result_arrow.json 82 | python benchmarks/comparison/run_whenever.py --fast -o \ 83 | benchmarks/comparison/result_whenever.json 84 | python -m pyperf compare_to benchmarks/comparison/result_stdlib_dateutil.json \ 85 | benchmarks/comparison/result_pendulum.json \ 86 | benchmarks/comparison/result_arrow.json \ 87 | benchmarks/comparison/result_whenever.json \ 88 | --table 89 | -------------------------------------------------------------------------------- /pysrc/whenever/_tz/system.py: -------------------------------------------------------------------------------- 1 | import os 2 | import os.path 3 | import platform 4 | from typing import Literal, Optional 5 | 6 | ZONEINFO = "zoneinfo" 7 | SYSTEM = platform.system() 8 | LOCALTIME = "/etc/localtime" 9 | 10 | # Getting the system timezone key and file depends on the platform. 11 | # On unix-like systems it's relatively straightforward. 12 | # On other platforms, we use the tzlocal package. 13 | # This keeps dependencies minimal for linux. 14 | if SYSTEM in ("Linux", "Darwin"): # pragma: no cover 15 | 16 | def _key_or_file() -> tuple[Literal[0, 1], str]: 17 | tzif_path = os.path.realpath(LOCALTIME) 18 | if tzif_path == LOCALTIME: 19 | # If the file is not a symlink, we can't determine the tzid 20 | return (1, LOCALTIME) # pragma: no cover 21 | 22 | if (tzid := _tzid_from_path(tzif_path)) is None: 23 | # If the file is not in a zoneinfo directory, we can't determine the tzid 24 | return (1, tzif_path) 25 | else: 26 | return (0, tzid) 27 | 28 | else: # pragma: no cover 29 | import tzlocal 30 | 31 | def _key_or_file() -> tuple[Literal[0, 1], str]: 32 | return (0, tzlocal.get_localzone_name()) 33 | 34 | 35 | def _tzid_from_path(path: str) -> Optional[str]: 36 | """Find the IANA timezone ID from a path to a zoneinfo file. 37 | Returns None if the path is not in a zoneinfo directory. 38 | """ 39 | # Find the path segment containing 'zoneinfo', 40 | # e.g. `zoneinfo/` or `zoneinfo.default/` 41 | if (index := path.find("/", path.rfind("zoneinfo"))) == -1: 42 | return None 43 | return path[index + 1 :] 44 | 45 | 46 | def get_tz() -> tuple[Literal[0, 1, 2], str]: 47 | """Get the system timezone. The timezone can be determined in different ways. 48 | The first item in the tuple is the type of the timezone: 49 | - 0: zoneinfo key 50 | - 1: file path to a zoneinfo file (key unknown) 51 | - 2: zoneinfo key or posix TZ string (unknown which) 52 | 53 | (This somewhat awkward API is used so this function can be used easily 54 | from Rust code) 55 | 56 | """ 57 | try: 58 | tz_env = os.environ["TZ"] 59 | except KeyError: # pragma: no cover 60 | return _key_or_file() 61 | else: 62 | if tz_env.startswith(":"): 63 | tz_env = tz_env[1:] # strip leading colon 64 | 65 | # Unless it's an absolute path, there's no way to strictly determine 66 | # if this is a zoneinfo key or a posix TZ string. 67 | if os.path.isabs(tz_env): 68 | return (1, tz_env) 69 | # If there's a digit, it may be a posix TZ string. Theoretically 70 | # a zoneinfo key could contain a digit too. 71 | elif any(c.isdigit() for c in tz_env): 72 | return (2, tz_env) 73 | else: 74 | # no digit: it's certainly a zoneinfo key 75 | return (0, tz_env) 76 | -------------------------------------------------------------------------------- /src/pymodule/utils.rs: -------------------------------------------------------------------------------- 1 | //! Miscellaneous utilities for the `whenever` module definition. 2 | use crate::py::*; 3 | use pyo3_ffi::*; 4 | use std::{ffi::CStr, ptr::null_mut as NULL}; 5 | 6 | /// Create and add a new enum type to the module 7 | pub(crate) fn new_enum( 8 | module: PyModule, 9 | module_name: PyObj, 10 | name: &str, 11 | members: &str, // space-separated list of members 12 | ) -> PyResult> { 13 | let tp = (name.to_py()?, members.to_py()?).into_pytuple()?; 14 | let enum_cls = import(c"enum")? 15 | .getattr(c"Enum")? 16 | .call(tp.borrow())? 17 | .cast_allow_subclass::() 18 | .unwrap(); 19 | 20 | enum_cls.setattr(c"__module__", module_name)?; 21 | 22 | module.add_type(enum_cls.borrow())?; 23 | Ok(enum_cls) 24 | } 25 | 26 | /// Create and add a new exception type to the module 27 | pub(crate) fn new_exception( 28 | module: PyModule, 29 | name: &CStr, 30 | doc: &CStr, 31 | base: *mut PyObject, 32 | ) -> PyResult> { 33 | // SAFETY: calling C API with valid arguments 34 | let e = unsafe { PyErr_NewExceptionWithDoc(name.as_ptr(), doc.as_ptr(), base, NULL()) } 35 | .rust_owned()?; 36 | module.add_type(e.borrow().cast_allow_subclass::().unwrap())?; 37 | Ok(e) 38 | } 39 | 40 | /// Create a new class in the module, including configuring the 41 | /// unpickler and setting the module name 42 | pub(crate) fn new_class( 43 | module: PyModule, 44 | module_nameobj: PyObj, 45 | spec: &mut PyType_Spec, 46 | unpickle_name: &CStr, 47 | ) -> PyResult<(Owned>, Owned)> { 48 | let cls = unsafe { PyType_FromModuleAndSpec(module.as_ptr(), spec, NULL()) } 49 | .rust_owned()? 50 | .cast_allow_subclass::() 51 | .unwrap() 52 | .map(|t| unsafe { t.link_type::() }); 53 | module.add_type(cls.borrow().into())?; 54 | 55 | let unpickler = module.getattr(unpickle_name)?; 56 | unpickler.setattr(c"__module__", module_nameobj)?; 57 | Ok((cls, unpickler)) 58 | } 59 | 60 | pub(crate) fn create_singletons( 61 | cls: HeapType, 62 | objs: &[(&CStr, T)], 63 | ) -> PyResult<()> { 64 | // SAFETY: each type is guaranteed to have tp_dict 65 | let cls_dict = 66 | unsafe { PyDict::from_ptr_unchecked((*cls.as_ptr().cast::()).tp_dict) }; 67 | for (name, value) in objs { 68 | let pyvalue = value.to_obj(cls)?; 69 | cls_dict 70 | // NOTE: We drop the value here, but count on the class dict to 71 | // keep the reference alive. This is safe since the dict is blocked 72 | // from mutation by the Py_TPFLAGS_IMMUTABLETYPE flag. 73 | .set_item_str(name, pyvalue.borrow())?; 74 | } 75 | Ok(()) 76 | } 77 | 78 | /// Intern a string in the Python interpreter 79 | pub(crate) fn intern(s: &CStr) -> PyReturn { 80 | unsafe { PyUnicode_InternFromString(s.as_ptr()) }.rust_owned() 81 | } 82 | -------------------------------------------------------------------------------- /src/py/exc.rs: -------------------------------------------------------------------------------- 1 | //! Utilities for dealing with Python exceptions. 2 | use std::ffi::CStr; 3 | 4 | use super::{base::*, refs::*}; 5 | use pyo3_ffi::*; 6 | 7 | // We use `Result` to implement Python's error handling. 8 | // Note that Python's error handling doesn't map exactly onto Rust's `Result` type, 9 | // The most important difference being that Python's error handling 10 | // is based on a global error indicator. 11 | // This means that some `Result` functionality will not behave as expected. 12 | // However, this is a price we can pay in exchange for the convenience 13 | // of the `?` operator. 14 | #[derive(Debug, Copy, Clone, PartialEq, Eq, PartialOrd, Ord)] 15 | pub(crate) struct PyErrMarker(); // sentinel that the Python error indicator is set 16 | 17 | pub(crate) type PyResult = Result; 18 | pub(crate) type PyReturn = PyResult>; 19 | 20 | pub(crate) fn raise(exc: *mut PyObject, msg: U) -> PyResult { 21 | Err(exception(exc, msg)) 22 | } 23 | 24 | pub(crate) fn exception(exc: *mut PyObject, msg: U) -> PyErrMarker { 25 | // If the message conversion fails, an error is set for us. 26 | // It's mostly likely a MemoryError. 27 | if let Ok(m) = msg.to_py() { 28 | unsafe { PyErr_SetObject(exc, m.as_ptr()) } 29 | }; 30 | PyErrMarker() 31 | } 32 | 33 | pub(crate) fn value_err(msg: U) -> PyErrMarker { 34 | exception(unsafe { PyExc_ValueError }, msg) 35 | } 36 | 37 | pub(crate) trait OptionExt { 38 | fn ok_or_else_raise(self, exc: *mut PyObject, fmt: F) -> PyResult 39 | where 40 | Self: Sized, 41 | F: FnOnce() -> M; 42 | 43 | fn ok_or_raise(self, exc: *mut PyObject, msg: U) -> PyResult 44 | where 45 | Self: Sized, 46 | { 47 | self.ok_or_else_raise(exc, || msg) 48 | } 49 | 50 | fn ok_or_value_err(self, msg: U) -> PyResult 51 | where 52 | Self: Sized, 53 | { 54 | self.ok_or_raise(unsafe { PyExc_ValueError }, msg) 55 | } 56 | 57 | fn ok_or_else_value_err(self, fmt: F) -> PyResult 58 | where 59 | Self: Sized, 60 | F: FnOnce() -> M, 61 | { 62 | unsafe { self.ok_or_else_raise(PyExc_ValueError, fmt) } 63 | } 64 | 65 | fn ok_or_else_type_err(self, fmt: F) -> PyResult 66 | where 67 | Self: Sized, 68 | F: FnOnce() -> M, 69 | { 70 | unsafe { self.ok_or_else_raise(PyExc_TypeError, fmt) } 71 | } 72 | 73 | fn ok_or_type_err(self, msg: U) -> PyResult 74 | where 75 | Self: Sized, 76 | { 77 | self.ok_or_raise(unsafe { PyExc_TypeError }, msg) 78 | } 79 | } 80 | 81 | impl OptionExt for Option { 82 | fn ok_or_else_raise(self, exc: *mut PyObject, fmt: F) -> PyResult 83 | where 84 | F: FnOnce() -> M, 85 | { 86 | match self { 87 | Some(x) => Ok(x), 88 | None => raise(exc, fmt()), 89 | } 90 | } 91 | } 92 | 93 | pub(crate) fn raise_type_err(msg: U) -> PyResult { 94 | raise(unsafe { PyExc_TypeError }, msg) 95 | } 96 | 97 | pub(crate) fn raise_value_err(msg: U) -> PyResult { 98 | raise(unsafe { PyExc_ValueError }, msg) 99 | } 100 | 101 | pub(crate) fn deprecation_warn(msg: &CStr) -> PyResult<()> { 102 | // SAFETY: calling C API with valid arguments 103 | match unsafe { PyErr_WarnEx(PyExc_DeprecationWarning, msg.as_ptr(), 1) } { 104 | 0 => Ok(()), 105 | _ => Err(PyErrMarker()), 106 | } 107 | } 108 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [project] 2 | name = "whenever" 3 | authors = [ 4 | {name = "Arie Bovenberg", email = "a.c.bovenberg@gmail.com"}, 5 | ] 6 | maintainers = [ 7 | {name = "Arie Bovenberg", email = "a.c.bovenberg@gmail.com"}, 8 | ] 9 | readme = "README.md" 10 | version = "0.9.4" 11 | license = "MIT" 12 | description = "Modern datetime library for Python" 13 | requires-python = ">=3.9" 14 | classifiers = [ 15 | "Development Status :: 4 - Beta", 16 | "Intended Audience :: Developers", 17 | "Operating System :: MacOS", 18 | "Operating System :: Microsoft :: Windows", 19 | "Operating System :: POSIX :: Linux", 20 | "Programming Language :: Python :: 3", 21 | "Programming Language :: Python :: 3.9", 22 | "Programming Language :: Python :: 3.10", 23 | "Programming Language :: Python :: 3.11", 24 | "Programming Language :: Python :: 3.12", 25 | "Programming Language :: Python :: 3.13", 26 | "Programming Language :: Python :: 3.14", 27 | "Programming Language :: Python :: Implementation :: CPython", 28 | "Programming Language :: Python :: Implementation :: PyPy", 29 | "Programming Language :: Python :: Free Threading :: 2 - Beta", 30 | "Programming Language :: Python", 31 | "Programming Language :: Rust", 32 | "Typing :: Typed", 33 | ] 34 | dependencies = [ 35 | "tzdata>=2020.1; sys_platform == 'win32'", 36 | "tzlocal>=4.0; sys_platform != 'darwin' and sys_platform != 'linux'", 37 | ] 38 | keywords = [ 39 | "datetime", "typesafe", "rust", "date", "time", "timezone", "utc", 40 | "zoneinfo", "tzdata", "tzdb" 41 | ] 42 | 43 | 44 | [project.urls] 45 | Documentation = "https://whenever.readthedocs.io" 46 | Repository = "https://github.com/ariebovenberg/whenever" 47 | Issues = "https://github.com/ariebovenberg/whenever/issues" 48 | Changelog = "https://github.com/ariebovenberg/whenever/blob/main/CHANGELOG.md" 49 | 50 | [tool.black] 51 | line-length = 79 52 | include = '\.pyi?$' 53 | exclude = ''' 54 | /( 55 | \.eggs 56 | | \.hg 57 | | \.git 58 | | \.mypy_cache 59 | | \.venv 60 | | _build 61 | | build 62 | | dist 63 | )/ 64 | ''' 65 | 66 | [tool.isort] 67 | profile = 'black' 68 | line_length = 79 69 | combine_as_imports = true 70 | 71 | [tool.mypy] 72 | warn_unused_ignores = true 73 | strict = true 74 | 75 | [[tool.mypy.overrides]] 76 | module = [ 77 | "tests.*", 78 | ] 79 | check_untyped_defs = true 80 | disable_error_code = ["no-untyped-def"] 81 | 82 | # ignore errors in extension module 83 | [[tool.mypy.overrides]] 84 | module = [ 85 | "whenever._whenever", 86 | "_interpreters", 87 | "tzdata", 88 | "tzlocal", 89 | "pydantic_core", 90 | "pydantic", 91 | ] 92 | ignore_missing_imports = true 93 | 94 | [tool.setuptools.packages] 95 | find = { where = ["pysrc"] } 96 | 97 | # We essentially use three build systems: 98 | # - We use maturin to build the binary distributions for different 99 | # platforms in CI, since it is very convenient for this purpose. 100 | # - We need setuptools to allow enabling/disabling building the Rust 101 | # extension using the environment variable, since maturin does not support this. 102 | # - To actually build the Rust extension with setuptools, we need 103 | # setuptools_rust. 104 | [tool.maturin] 105 | python-source = "pysrc" 106 | module-name = "whenever._whenever" 107 | 108 | [build-system] 109 | requires = [ 110 | "setuptools", 111 | "wheel", 112 | ] 113 | # We need a custom backend to make the inclusion of setuptools_rust conditional 114 | # on the environment variable. 115 | # Follows the approach from setuptools.pypa.io/en/latest/build_meta.html#dynamic-build-dependencies-and-other-build-meta-tweaks 116 | build-backend = "backend" 117 | backend-path = ["_custom_pybuild"] 118 | -------------------------------------------------------------------------------- /src/py/refs.rs: -------------------------------------------------------------------------------- 1 | //! Functionality relating to ownership and references 2 | use super::{base::*, exc::*}; 3 | use core::mem::ManuallyDrop; 4 | use core::ptr::null_mut as NULL; 5 | use pyo3_ffi::*; 6 | 7 | /// A wrapper for Python objects that have a reference owned by Rust. 8 | /// They are decreferred on drop. 9 | #[derive(Debug)] 10 | pub(crate) struct Owned { 11 | inner: T, 12 | } 13 | 14 | impl Owned { 15 | pub(crate) fn new(inner: T) -> Self { 16 | Self { inner } 17 | } 18 | 19 | pub(crate) fn py_owned(self) -> T { 20 | // By transferring ownership to Python, we essentially say 21 | // Rust is no longer responsible for the memory (i.e. Drop) 22 | let this = ManuallyDrop::new(self); 23 | unsafe { 24 | std::ptr::read(&this.inner) // Read the inner object without dropping it 25 | } 26 | } 27 | 28 | pub(crate) fn borrow(&self) -> T { 29 | self.inner 30 | } 31 | 32 | /// Apply a function to the inner object while retaining ownership. 33 | pub(crate) fn map(self, f: F) -> Owned 34 | where 35 | F: FnOnce(T) -> U, 36 | U: PyBase, 37 | { 38 | Owned::new(f(self.py_owned())) 39 | } 40 | } 41 | 42 | impl Owned { 43 | pub(crate) fn cast_exact(self) -> Option> { 44 | let inner = self.py_owned(); 45 | inner.as_py_obj().cast_exact().map(Owned::new).or_else(|| { 46 | // Casting failed, but don't forget to decref the original object 47 | unsafe { Py_DECREF(inner.as_ptr()) }; 48 | None 49 | }) 50 | } 51 | 52 | pub(crate) fn cast_allow_subclass(self) -> Option> { 53 | let inner = self.py_owned(); 54 | inner 55 | .as_py_obj() 56 | .cast_allow_subclass() 57 | .map(Owned::new) 58 | .or_else(|| { 59 | // Casting failed, but don't forget to decref the original object 60 | unsafe { Py_DECREF(inner.as_ptr()) }; 61 | None 62 | }) 63 | } 64 | 65 | pub(crate) unsafe fn cast_unchecked(self) -> Owned { 66 | // SAFETY: the caller guarantees the type 67 | Owned::new(unsafe { self.py_owned().as_py_obj().cast_unchecked() }) 68 | } 69 | } 70 | 71 | impl Drop for Owned { 72 | fn drop(&mut self) { 73 | unsafe { 74 | // SAFETY: we hold a reference to the object, so it's guaranteed to be valid 75 | Py_DECREF(self.inner.as_ptr()); 76 | } 77 | } 78 | } 79 | 80 | impl std::ops::Deref for Owned { 81 | type Target = T; 82 | 83 | fn deref(&self) -> &Self::Target { 84 | &self.inner 85 | } 86 | } 87 | 88 | impl std::ops::DerefMut for Owned { 89 | fn deref_mut(&mut self) -> &mut Self::Target { 90 | &mut self.inner 91 | } 92 | } 93 | 94 | pub(crate) trait PyObjectExt { 95 | fn rust_owned(self) -> PyResult>; 96 | } 97 | 98 | impl PyObjectExt for *mut PyObject { 99 | fn rust_owned(self) -> PyResult> { 100 | PyObj::new(self).map(Owned::new) 101 | } 102 | } 103 | 104 | pub(crate) trait ToPyOwnedPtr { 105 | fn to_py_owned_ptr(self) -> *mut PyObject; 106 | } 107 | 108 | impl ToPyOwnedPtr for *mut PyObject { 109 | fn to_py_owned_ptr(self) -> *mut PyObject { 110 | self 111 | } 112 | } 113 | 114 | impl ToPyOwnedPtr for PyResult> { 115 | fn to_py_owned_ptr(self) -> *mut PyObject { 116 | match self.map(|x| x.py_owned()) { 117 | Ok(x) => x.as_ptr(), 118 | Err(_) => NULL(), 119 | } 120 | } 121 | } 122 | 123 | impl ToPyOwnedPtr for Owned { 124 | fn to_py_owned_ptr(self) -> *mut PyObject { 125 | self.py_owned().as_ptr() 126 | } 127 | } 128 | -------------------------------------------------------------------------------- /tests/common.py: -------------------------------------------------------------------------------- 1 | import os 2 | from contextlib import contextmanager 3 | from pathlib import Path 4 | from typing import Literal 5 | from unittest.mock import patch 6 | 7 | from whenever import PlainDateTime, ZonedDateTime, reset_system_tz 8 | 9 | # The POSIX TZ string for the Amsterdam timezone. 10 | AMS_TZ_POSIX = "CET-1CEST,M3.5.0,M10.5.0/3" 11 | # A non-standard path to the Amsterdam timezone file, that can't be traced 12 | # back to the zoneinfo database. 13 | AMS_TZ_RAWFILE = str(Path(__file__).parent / "tzif" / "Amsterdam.tzif") 14 | 15 | 16 | class AlwaysEqual: 17 | def __eq__(self, _): 18 | return True 19 | 20 | 21 | class NeverEqual: 22 | def __eq__(self, _): 23 | return False 24 | 25 | 26 | class AlwaysLarger: 27 | def __lt__(self, _): 28 | return False 29 | 30 | def __le__(self, _): 31 | return False 32 | 33 | def __gt__(self, _): 34 | return True 35 | 36 | def __ge__(self, _): 37 | return True 38 | 39 | 40 | class AlwaysSmaller: 41 | def __lt__(self, _): 42 | return True 43 | 44 | def __le__(self, _): 45 | return True 46 | 47 | def __gt__(self, _): 48 | return False 49 | 50 | def __ge__(self, _): 51 | return False 52 | 53 | 54 | @contextmanager 55 | def system_tz_ams(): 56 | try: 57 | with patch.dict(os.environ, {"TZ": "Europe/Amsterdam"}): 58 | reset_system_tz() 59 | yield 60 | finally: 61 | reset_system_tz() # don't forget to reset the timezone after the patch! 62 | 63 | 64 | @contextmanager 65 | def system_tz(name): 66 | try: 67 | with patch.dict(os.environ, {"TZ": name}): 68 | reset_system_tz() 69 | yield 70 | finally: 71 | reset_system_tz() # don't forget to reset the timezone after the patch! 72 | 73 | 74 | @contextmanager 75 | def system_tz_nyc(): 76 | try: 77 | with patch.dict(os.environ, {"TZ": "America/New_York"}): 78 | reset_system_tz() 79 | yield 80 | finally: 81 | reset_system_tz() # don't forget to reset the timezone after the patch! 82 | 83 | 84 | with system_tz(AMS_TZ_POSIX): 85 | _AMS_POSIX_DT = PlainDateTime(2023, 3, 26, 2, 30).assume_system_tz() 86 | 87 | with system_tz(AMS_TZ_RAWFILE): 88 | _AMS_RAWFILE_DT = PlainDateTime(2023, 3, 26, 2, 30).assume_system_tz() 89 | 90 | 91 | def create_zdt( 92 | year: int, 93 | month: int, 94 | day: int, 95 | hour: int = 0, 96 | minute: int = 0, 97 | second: int = 0, 98 | nanosecond: int = 0, 99 | *, 100 | tz: str = "", 101 | disambiguate: Literal[ 102 | "compatible", "earlier", "later", "raise" 103 | ] = "compatible", 104 | ) -> ZonedDateTime: 105 | """Convenience method to create a ZonedDateTime object, potentially 106 | with system timezone.""" 107 | # A special check that is only useful in tests of course 108 | if tz == AMS_TZ_POSIX: 109 | return _AMS_POSIX_DT.replace( 110 | year=year, 111 | month=month, 112 | day=day, 113 | hour=hour, 114 | minute=minute, 115 | second=second, 116 | nanosecond=nanosecond, 117 | disambiguate=disambiguate, 118 | ) 119 | elif tz == AMS_TZ_RAWFILE: 120 | return _AMS_RAWFILE_DT.replace( 121 | year=year, 122 | month=month, 123 | day=day, 124 | hour=hour, 125 | minute=minute, 126 | second=second, 127 | nanosecond=nanosecond, 128 | disambiguate=disambiguate, 129 | ) 130 | else: 131 | return ZonedDateTime( 132 | year, 133 | month, 134 | day, 135 | hour, 136 | minute, 137 | second, 138 | nanosecond=nanosecond, 139 | tz=tz, 140 | disambiguate=disambiguate, 141 | ) 142 | -------------------------------------------------------------------------------- /src/py/tuple.rs: -------------------------------------------------------------------------------- 1 | //! Functions for dealing with Python tuples. 2 | use super::{base::*, exc::*, refs::*}; 3 | use pyo3_ffi::*; 4 | 5 | #[derive(Debug, Clone, Copy, PartialEq, Eq)] 6 | pub(crate) struct PyTuple { 7 | obj: PyObj, 8 | } 9 | 10 | impl PyBase for PyTuple { 11 | fn as_py_obj(&self) -> PyObj { 12 | self.obj 13 | } 14 | } 15 | 16 | impl FromPy for PyTuple { 17 | unsafe fn from_ptr_unchecked(ptr: *mut PyObject) -> Self { 18 | Self { 19 | obj: unsafe { PyObj::from_ptr_unchecked(ptr) }, 20 | } 21 | } 22 | } 23 | 24 | impl PyStaticType for PyTuple { 25 | fn isinstance_exact(obj: impl PyBase) -> bool { 26 | unsafe { PyTuple_CheckExact(obj.as_ptr()) != 0 } 27 | } 28 | 29 | fn isinstance(obj: impl PyBase) -> bool { 30 | unsafe { PyTuple_Check(obj.as_ptr()) != 0 } 31 | } 32 | } 33 | 34 | impl PyTuple { 35 | pub(crate) fn len(&self) -> Py_ssize_t { 36 | unsafe { PyTuple_GET_SIZE(self.obj.as_ptr()) } 37 | } 38 | 39 | pub(crate) fn iter(&self) -> PyTupleIter { 40 | PyTupleIter { 41 | obj: self.as_ptr(), 42 | index: 0, 43 | size: self.len(), 44 | } 45 | } 46 | } 47 | 48 | pub(crate) struct PyTupleIter { 49 | obj: *mut PyObject, 50 | index: Py_ssize_t, 51 | size: Py_ssize_t, 52 | } 53 | 54 | impl Iterator for PyTupleIter { 55 | type Item = PyObj; 56 | 57 | fn next(&mut self) -> Option { 58 | if self.index >= self.size { 59 | return None; 60 | } 61 | let result = unsafe { PyObj::from_ptr_unchecked(PyTuple_GET_ITEM(self.obj, self.index)) }; 62 | self.index += 1; 63 | Some(result) 64 | } 65 | } 66 | 67 | pub(crate) trait IntoPyTuple { 68 | fn into_pytuple(self) -> PyResult>; 69 | } 70 | 71 | impl IntoPyTuple for (Owned,) { 72 | fn into_pytuple(self) -> PyResult> { 73 | let tuple = unsafe { PyTuple_New(1).rust_owned()?.cast_unchecked::() }; 74 | unsafe { PyTuple_SET_ITEM(tuple.as_ptr(), 0, self.0.py_owned().as_ptr()) }; 75 | Ok(tuple) 76 | } 77 | } 78 | 79 | impl IntoPyTuple for (Owned, Owned) { 80 | fn into_pytuple(self) -> PyResult> { 81 | let tuple = unsafe { PyTuple_New(2).rust_owned()?.cast_unchecked::() }; 82 | unsafe { PyTuple_SET_ITEM(tuple.as_ptr(), 0, self.0.py_owned().as_ptr()) }; 83 | unsafe { PyTuple_SET_ITEM(tuple.as_ptr(), 1, self.1.py_owned().as_ptr()) }; 84 | Ok(tuple) 85 | } 86 | } 87 | 88 | impl IntoPyTuple for (Owned, Owned, Owned) { 89 | fn into_pytuple(self) -> PyResult> { 90 | let tuple = unsafe { PyTuple_New(3).rust_owned()?.cast_unchecked::() }; 91 | unsafe { PyTuple_SET_ITEM(tuple.as_ptr(), 0, self.0.py_owned().as_ptr()) }; 92 | unsafe { PyTuple_SET_ITEM(tuple.as_ptr(), 1, self.1.py_owned().as_ptr()) }; 93 | unsafe { PyTuple_SET_ITEM(tuple.as_ptr(), 2, self.2.py_owned().as_ptr()) }; 94 | Ok(tuple) 95 | } 96 | } 97 | 98 | impl IntoPyTuple 99 | for (Owned, Owned, Owned, Owned) 100 | { 101 | fn into_pytuple(self) -> PyResult> { 102 | let tuple = unsafe { PyTuple_New(4).rust_owned()?.cast_unchecked::() }; 103 | unsafe { PyTuple_SET_ITEM(tuple.as_ptr(), 0, self.0.py_owned().as_ptr()) }; 104 | unsafe { PyTuple_SET_ITEM(tuple.as_ptr(), 1, self.1.py_owned().as_ptr()) }; 105 | unsafe { PyTuple_SET_ITEM(tuple.as_ptr(), 2, self.2.py_owned().as_ptr()) }; 106 | unsafe { PyTuple_SET_ITEM(tuple.as_ptr(), 3, self.3.py_owned().as_ptr()) }; 107 | Ok(tuple) 108 | } 109 | } 110 | 111 | impl IntoPyTuple 112 | for (Owned, Owned, Owned, Owned, Owned) 113 | { 114 | fn into_pytuple(self) -> PyResult> { 115 | let tuple = unsafe { PyTuple_New(5).rust_owned()?.cast_unchecked::() }; 116 | unsafe { PyTuple_SET_ITEM(tuple.as_ptr(), 0, self.0.py_owned().as_ptr()) }; 117 | unsafe { PyTuple_SET_ITEM(tuple.as_ptr(), 1, self.1.py_owned().as_ptr()) }; 118 | unsafe { PyTuple_SET_ITEM(tuple.as_ptr(), 2, self.2.py_owned().as_ptr()) }; 119 | unsafe { PyTuple_SET_ITEM(tuple.as_ptr(), 3, self.3.py_owned().as_ptr()) }; 120 | unsafe { PyTuple_SET_ITEM(tuple.as_ptr(), 4, self.4.py_owned().as_ptr()) }; 121 | Ok(tuple) 122 | } 123 | } 124 | -------------------------------------------------------------------------------- /src/py/args.rs: -------------------------------------------------------------------------------- 1 | //! Functions for handling arguments and keyword arguments in Python 2 | use crate::py::*; 3 | use pyo3_ffi::*; 4 | 5 | pub(crate) struct IterKwargs { 6 | keys: *mut PyObject, 7 | values: *const *mut PyObject, 8 | size: isize, 9 | pos: isize, 10 | } 11 | 12 | impl IterKwargs { 13 | pub(crate) unsafe fn new(keys: *mut PyObject, values: *const *mut PyObject) -> Self { 14 | Self { 15 | keys, 16 | values, 17 | size: if keys.is_null() { 18 | 0 19 | } else { 20 | // SAFETY: calling C API with valid arguments 21 | unsafe { PyTuple_GET_SIZE(keys) as isize } 22 | }, 23 | pos: 0, 24 | } 25 | } 26 | 27 | pub(crate) fn len(&self) -> isize { 28 | self.size 29 | } 30 | } 31 | 32 | impl Iterator for IterKwargs { 33 | type Item = (PyObj, PyObj); 34 | 35 | fn next(&mut self) -> Option { 36 | if self.pos == self.size { 37 | return None; 38 | } 39 | let item = unsafe { 40 | ( 41 | PyObj::from_ptr_unchecked(PyTuple_GET_ITEM(self.keys, self.pos)), 42 | PyObj::from_ptr_unchecked(*self.values.offset(self.pos)), 43 | ) 44 | }; 45 | self.pos += 1; 46 | Some(item) 47 | } 48 | } 49 | 50 | macro_rules! parse_args_kwargs { 51 | ($args:ident, $kwargs:ident, $fmt:expr, $($var:ident),* $(,)?) => { 52 | // SAFETY: calling CPython API with valid arguments 53 | unsafe { 54 | const _ARGNAMES: *mut *const std::ffi::c_char = [ 55 | $( 56 | concat!(stringify!($var), "\0").as_ptr() as *const std::ffi::c_char, 57 | )* 58 | std::ptr::null(), 59 | ].as_ptr() as *mut _; 60 | if PyArg_ParseTupleAndKeywords( 61 | $args.as_ptr(), 62 | $kwargs.map_or(NULL(), |d| d.as_ptr()), 63 | $fmt.as_ptr(), 64 | { 65 | // This API was changed in Python 3.13 66 | #[cfg(Py_3_13)] 67 | { 68 | _ARGNAMES 69 | } 70 | #[cfg(not(Py_3_13))] 71 | { 72 | _ARGNAMES as *mut *mut _ 73 | } 74 | }, 75 | $(&mut $var,)* 76 | ) == 0 { 77 | return Err(PyErrMarker()); 78 | } 79 | } 80 | }; 81 | } 82 | 83 | #[inline] 84 | fn ptr_eq(a: PyObj, b: PyObj) -> bool { 85 | a == b 86 | } 87 | 88 | #[inline] 89 | fn value_eq(a: PyObj, b: PyObj) -> bool { 90 | unsafe { PyObject_RichCompareBool(a.as_ptr(), b.as_ptr(), Py_EQ) == 1 } 91 | } 92 | 93 | pub(crate) fn handle_kwargs(fname: &str, kwargs: K, mut handler: F) -> PyResult<()> 94 | where 95 | F: FnMut(PyObj, PyObj, fn(PyObj, PyObj) -> bool) -> PyResult, 96 | K: IntoIterator, 97 | { 98 | for (key, value) in kwargs { 99 | // First we try to match *all kwargs* on pointer equality. 100 | // This is actually the common case, as static strings are interned. 101 | // In the rare case they aren't, we fall back to value comparison. 102 | // Doing it this way is faster than always doing value comparison outright. 103 | if !handler(key, value, ptr_eq)? && !handler(key, value, value_eq)? { 104 | return raise_type_err(format!( 105 | "{fname}() got an unexpected keyword argument: {key}" 106 | )); 107 | } 108 | } 109 | Ok(()) 110 | } 111 | 112 | /// Helper to efficiently match a value against a set of known interned strings. 113 | /// The handler closure is called twice, first with pointer equality (very fast), 114 | /// and only if that fails, with value equality (slower). 115 | /// 116 | /// NOTE: although Python's string equality also uses this trick, it does so 117 | /// on a per-object basis, so it will still end up running slower equality checks 118 | /// multiple times. By doing it this way, we end up with only pointer equality 119 | /// checks for the common case of interned strings. 120 | pub(crate) fn match_interned_str(name: &str, value: PyObj, mut handler: F) -> PyResult 121 | where 122 | F: FnMut(PyObj, fn(PyObj, PyObj) -> bool) -> Option, 123 | { 124 | handler(value, ptr_eq) 125 | .or_else(|| handler(value, value_eq)) 126 | .ok_or_else_value_err(|| format!("Invalid value for {name}: {value}")) 127 | } 128 | 129 | pub(crate) use parse_args_kwargs; 130 | -------------------------------------------------------------------------------- /src/py/num.rs: -------------------------------------------------------------------------------- 1 | //! Functionality for Python's int and float types 2 | use super::{base::*, exc::*, refs::*}; 3 | use core::ffi::c_long; 4 | use core::mem; 5 | use pyo3_ffi::*; 6 | 7 | #[derive(Debug, Clone, Copy, PartialEq, Eq)] 8 | pub(crate) struct PyInt { 9 | obj: PyObj, 10 | } 11 | 12 | impl PyBase for PyInt { 13 | fn as_py_obj(&self) -> PyObj { 14 | self.obj 15 | } 16 | } 17 | 18 | impl FromPy for PyInt { 19 | unsafe fn from_ptr_unchecked(ptr: *mut PyObject) -> Self { 20 | Self { 21 | obj: unsafe { PyObj::from_ptr_unchecked(ptr) }, 22 | } 23 | } 24 | } 25 | 26 | impl PyStaticType for PyInt { 27 | fn isinstance_exact(obj: impl PyBase) -> bool { 28 | unsafe { PyLong_CheckExact(obj.as_ptr()) != 0 } 29 | } 30 | 31 | fn isinstance(obj: impl PyBase) -> bool { 32 | unsafe { PyLong_Check(obj.as_ptr()) != 0 } 33 | } 34 | } 35 | 36 | impl PyInt { 37 | pub(crate) fn to_long(self) -> PyResult { 38 | match unsafe { PyLong_AsLong(self.as_ptr()) } { 39 | x if x != -1 || unsafe { PyErr_Occurred() }.is_null() => Ok(x), 40 | // The error message is set for us 41 | _ => Err(PyErrMarker()), 42 | } 43 | } 44 | 45 | pub(crate) fn to_i64(self) -> PyResult { 46 | match unsafe { PyLong_AsLongLong(self.as_ptr()) } { 47 | x if x != -1 || unsafe { PyErr_Occurred() }.is_null() => Ok(x), 48 | // The error message is set for us 49 | _ => Err(PyErrMarker()), 50 | } 51 | } 52 | 53 | pub(crate) fn to_i128(self) -> PyResult { 54 | let mut bytes: [u8; 16] = [0; 16]; 55 | // Yes, this is a private API, but it's the only way to get a 128-bit integer 56 | // on Python < 3.13. Other libraries do this too. 57 | if unsafe { _PyLong_AsByteArray(self.as_ptr().cast(), &mut bytes as *mut _, 16, 1, 1) } == 0 58 | { 59 | Ok(i128::from_le_bytes(bytes)) 60 | } else { 61 | raise( 62 | unsafe { PyExc_OverflowError }, 63 | "Python int too large to convert to i128", 64 | ) 65 | } 66 | } 67 | } 68 | 69 | #[derive(Debug, Clone, Copy, PartialEq, Eq)] 70 | pub(crate) struct PyFloat { 71 | obj: PyObj, 72 | } 73 | 74 | impl PyBase for PyFloat { 75 | fn as_py_obj(&self) -> PyObj { 76 | self.obj 77 | } 78 | } 79 | 80 | impl FromPy for PyFloat { 81 | unsafe fn from_ptr_unchecked(ptr: *mut PyObject) -> Self { 82 | Self { 83 | obj: unsafe { PyObj::from_ptr_unchecked(ptr) }, 84 | } 85 | } 86 | } 87 | 88 | impl PyStaticType for PyFloat { 89 | fn isinstance_exact(obj: impl PyBase) -> bool { 90 | unsafe { PyFloat_CheckExact(obj.as_ptr()) != 0 } 91 | } 92 | 93 | fn isinstance(obj: impl PyBase) -> bool { 94 | unsafe { PyFloat_Check(obj.as_ptr()) != 0 } 95 | } 96 | } 97 | 98 | impl PyFloat { 99 | pub(crate) fn to_f64(self) -> PyResult { 100 | match unsafe { PyFloat_AsDouble(self.as_ptr()) } { 101 | x if x != -1.0 || unsafe { PyErr_Occurred() }.is_null() => Ok(x), 102 | // The error message is set for us 103 | _ => Err(PyErrMarker()), 104 | } 105 | } 106 | } 107 | 108 | impl ToPy for i128 { 109 | fn to_py(self) -> PyReturn { 110 | // Yes, this is a private API, but it's the only way to create a 128-bit integer 111 | // on Python < 3.13. Other libraries do this too. 112 | unsafe { 113 | _PyLong_FromByteArray( 114 | self.to_le_bytes().as_ptr().cast(), 115 | mem::size_of::(), 116 | 1, 117 | 1, 118 | ) 119 | } 120 | .rust_owned() 121 | } 122 | } 123 | 124 | impl ToPy for i64 { 125 | fn to_py(self) -> PyReturn { 126 | unsafe { PyLong_FromLongLong(self) }.rust_owned() 127 | } 128 | } 129 | 130 | impl ToPy for i32 { 131 | fn to_py(self) -> PyReturn { 132 | unsafe { PyLong_FromLong(self.into()) }.rust_owned() 133 | } 134 | } 135 | 136 | impl ToPy for f64 { 137 | fn to_py(self) -> PyReturn { 138 | unsafe { PyFloat_FromDouble(self) }.rust_owned() 139 | } 140 | } 141 | 142 | impl ToPy for u32 { 143 | fn to_py(self) -> PyReturn { 144 | unsafe { PyLong_FromUnsignedLong(self.into()) }.rust_owned() 145 | } 146 | } 147 | 148 | impl ToPy for u16 { 149 | fn to_py(self) -> PyReturn { 150 | unsafe { PyLong_FromUnsignedLong(self.into()) }.rust_owned() 151 | } 152 | } 153 | 154 | impl ToPy for u8 { 155 | fn to_py(self) -> PyReturn { 156 | unsafe { PyLong_FromUnsignedLong(self.into()) }.rust_owned() 157 | } 158 | } 159 | 160 | impl ToPy for bool { 161 | fn to_py(self) -> PyReturn { 162 | Ok(unsafe { 163 | PyObj::from_ptr_unchecked(match self { 164 | true => Py_True(), 165 | false => Py_False(), 166 | }) 167 | } 168 | .newref()) 169 | } 170 | } 171 | -------------------------------------------------------------------------------- /benchmarks/comparison/graph-vega-config.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://vega.github.io/schema/vega-lite/v6.json", 3 | "data": { 4 | "values": [ 5 | { 6 | "tool": "Whenever", 7 | "time": 0.37, 8 | "timeFormat": "0.4s" 9 | 10 | }, 11 | 12 | { 13 | "tool": "datetime", 14 | "time": 1.88, 15 | "timeFormat": "1.9s" 16 | 17 | }, 18 | { 19 | "tool": "Whenever (pure python)", 20 | "time": 9.1, 21 | "timeFormat": "9.1s" 22 | 23 | }, 24 | { 25 | "tool": "Arrow", 26 | "time": 34, 27 | "timeFormat": "34s" 28 | }, 29 | { 30 | "tool": "Pendulum", 31 | "time": 89, 32 | "timeFormat": "91s" 33 | }, 34 | ] 35 | }, 36 | "config": { 37 | "params": [ 38 | { 39 | "name": "defaultFont", 40 | "value": "-apple-system,BlinkMacSystemFont,\"Segoe UI\",Helvetica,Arial,sans-serif,\"Apple Color Emoji\",\"Segoe UI Emoji\"" 41 | }, 42 | { 43 | "name": "titleColor", 44 | "value": "#333333", 45 | //"value": "#C9D1D9" 46 | 47 | }, 48 | { 49 | "name": "labelColor", 50 | "value": "#333333", 51 | //"value": "#C9D1D9" 52 | } 53 | ], 54 | "header": { 55 | "labelFont": { 56 | "expr": "defaultFont" 57 | }, 58 | "titleFont": { 59 | "expr": "defaultFont" 60 | }, 61 | "titleFontWeight": 500 62 | }, 63 | "text": { 64 | "font": { 65 | "expr": "defaultFont" 66 | }, 67 | "color": { 68 | "expr": "labelColor" 69 | } 70 | }, 71 | "mark": { 72 | "font": { 73 | "expr": "defaultFont" 74 | }, 75 | "color": { 76 | "expr": "labelColor" 77 | } 78 | }, 79 | "title": { 80 | "font": { 81 | "expr": "defaultFont" 82 | }, 83 | "subtitleFont": { 84 | "expr": "defaultFont" 85 | }, 86 | "fontWeight": 500 87 | }, 88 | "axis": { 89 | "labelColor": { 90 | "expr": "labelColor" 91 | }, 92 | "labelFont": { 93 | "expr": "defaultFont" 94 | }, 95 | "titleFont": { 96 | "expr": "defaultFont" 97 | }, 98 | "titleFontWeight": 500, 99 | "titleColor": { 100 | "expr": "titleColor" 101 | }, 102 | "titleFontSize": 12 103 | }, 104 | "legend": { 105 | "titleFontWeight": 500, 106 | "titleColor": { 107 | "expr": "titleColor" 108 | }, 109 | "titleFontSize": 12, 110 | "labelColor": { 111 | "expr": "labelColor" 112 | }, 113 | "labelFont": { 114 | "expr": "defaultFont" 115 | }, 116 | "titleFont": { 117 | "expr": "defaultFont" 118 | } 119 | }, 120 | "view": { 121 | "stroke": null 122 | }, 123 | "background": "transparent" 124 | }, 125 | "background": "transparent", 126 | "encoding": { 127 | "y": { 128 | "field": "tool", 129 | "type": "nominal", 130 | "axis": { 131 | "grid": false, 132 | "title": null, 133 | "labelFontSize": 12, 134 | "ticks": false, 135 | "labelPadding": 10, 136 | "domain": false 137 | }, 138 | "sort": null 139 | }, 140 | "x": { 141 | "field": "time", 142 | "type": "quantitative", 143 | "axis": { 144 | "title": null, 145 | "labelExpr": "datum.value + 's'", 146 | "tickCount": 3, 147 | "tickSize": 0, 148 | "labelPadding": 6, 149 | "labelAlign": "center", 150 | "labelFontSize": 12, 151 | "tickColor": "rgba(127,127,127,0.25)", 152 | "gridColor": "rgba(127,127,127,0.25)", 153 | "domain": false 154 | }, 155 | //"scale": {"type": "log"} 156 | } 157 | }, 158 | "height": 105, 159 | "width": "container", 160 | "layer": [ 161 | { 162 | "mark": "bar", 163 | "encoding": { 164 | "size": { 165 | "value": 13 166 | }, 167 | "color": { 168 | "value": "#E15759" 169 | } 170 | } 171 | }, 172 | { 173 | "transform": [ 174 | { 175 | "filter": "datum.tool !== 'Whenever'" 176 | } 177 | ], 178 | "mark": { 179 | "type": "text", 180 | "align": "left", 181 | "baseline": "middle", 182 | "dx": 6, 183 | "fontSize": 12 184 | }, 185 | "encoding": { 186 | "text": { 187 | "field": "timeFormat" 188 | } 189 | } 190 | }, 191 | { 192 | "transform": [ 193 | { 194 | "filter": "datum.tool === 'Whenever'" 195 | } 196 | ], 197 | "mark": { 198 | "type": "text", 199 | "align": "left", 200 | "baseline": "middle", 201 | "dx": 6, 202 | "fontSize": 12, 203 | "fontWeight": "bold" 204 | }, 205 | "encoding": { 206 | "text": { 207 | "field": "timeFormat" 208 | } 209 | } 210 | } 211 | ] 212 | } 213 | -------------------------------------------------------------------------------- /src/pymodule/patch.rs: -------------------------------------------------------------------------------- 1 | //! Functionality related to patching the current time 2 | use crate::{classes::instant::Instant, common::scalar::*, py::*, pymodule::State}; 3 | use pyo3_ffi::*; 4 | use std::time::SystemTime; 5 | 6 | pub(crate) fn _patch_time_frozen(state: &mut State, arg: PyObj) -> PyReturn { 7 | _patch_time(state, arg, true) 8 | } 9 | 10 | pub(crate) fn _patch_time_keep_ticking(state: &mut State, arg: PyObj) -> PyReturn { 11 | _patch_time(state, arg, false) 12 | } 13 | 14 | pub(crate) fn _patch_time(state: &mut State, arg: PyObj, freeze: bool) -> PyReturn { 15 | let Some(inst) = arg.extract(state.instant_type) else { 16 | return raise_type_err("Expected an Instant")?; 17 | }; 18 | 19 | let pos_epoch = u64::try_from(inst.epoch.get()) 20 | .ok() 21 | .ok_or_type_err("Can only set time after 1970")?; 22 | 23 | let patch = &mut state.time_patch; 24 | 25 | patch.set_state(if freeze { 26 | PatchState::Frozen(inst) 27 | } else { 28 | PatchState::KeepTicking { 29 | pin: std::time::Duration::new(pos_epoch, inst.subsec.get() as _), 30 | at: SystemTime::now() 31 | .duration_since(SystemTime::UNIX_EPOCH) 32 | .ok() 33 | .ok_or_type_err("System time before 1970")?, 34 | } 35 | }); 36 | Ok(none()) 37 | } 38 | 39 | pub(crate) fn _unpatch_time(state: &mut State) -> PyReturn { 40 | let patch = &mut state.time_patch; 41 | patch.set_state(PatchState::Unset); 42 | Ok(none()) 43 | } 44 | 45 | #[derive(Debug, Clone, Copy)] 46 | pub(crate) struct Patch { 47 | state: PatchState, 48 | time_machine_installed: bool, 49 | } 50 | 51 | impl Patch { 52 | pub(crate) fn new() -> PyResult { 53 | Ok(Self { 54 | state: PatchState::Unset, 55 | time_machine_installed: time_machine_installed()?, 56 | }) 57 | } 58 | 59 | pub(crate) fn set_state(&mut self, state: PatchState) { 60 | self.state = state; 61 | } 62 | } 63 | 64 | fn time_machine_installed() -> PyResult { 65 | // Important: we don't import `time_machine` here, 66 | // because that would be slower. We only need to check its existence. 67 | Ok(!import(c"importlib.util")? 68 | .getattr(c"find_spec")? 69 | .call1("time_machine".to_py()?.borrow())? 70 | .is_none()) 71 | } 72 | 73 | #[derive(Debug, Clone, Copy)] 74 | pub(crate) enum PatchState { 75 | Unset, 76 | Frozen(Instant), 77 | KeepTicking { 78 | pin: std::time::Duration, 79 | at: std::time::Duration, 80 | }, 81 | } 82 | 83 | impl Instant { 84 | pub(crate) fn from_duration_since_epoch(d: std::time::Duration) -> Option { 85 | Some(Instant { 86 | epoch: EpochSecs::new(d.as_secs() as _)?, 87 | // Safe: subsec on Duration is always in range 88 | subsec: SubSecNanos::new_unchecked(d.subsec_nanos() as _), 89 | }) 90 | } 91 | 92 | fn from_nanos_i64(ns: i64) -> Option { 93 | Some(Instant { 94 | epoch: EpochSecs::new(ns / 1_000_000_000)?, 95 | subsec: SubSecNanos::from_remainder(ns), 96 | }) 97 | } 98 | } 99 | 100 | impl State { 101 | pub(crate) fn time_ns(&self) -> PyResult { 102 | let Patch { 103 | state: status, 104 | time_machine_installed, 105 | } = self.time_patch; 106 | match status { 107 | PatchState::Unset => { 108 | if time_machine_installed { 109 | self.time_ns_py() 110 | } else { 111 | self.time_ns_rust() 112 | } 113 | } 114 | PatchState::Frozen(e) => Ok(e), 115 | PatchState::KeepTicking { pin, at } => { 116 | let dur = pin 117 | + SystemTime::now() 118 | .duration_since(SystemTime::UNIX_EPOCH) 119 | .ok() 120 | .ok_or_raise(unsafe { PyExc_OSError }, "System time out of range")? 121 | - at; 122 | Instant::from_duration_since_epoch(dur) 123 | .ok_or_raise(unsafe { PyExc_OSError }, "System time out of range") 124 | } 125 | } 126 | } 127 | 128 | fn time_ns_py(&self) -> PyResult { 129 | let ts = self.time_ns.call0()?; 130 | let ns = ts 131 | .cast_exact::() 132 | .ok_or_raise( 133 | unsafe { PyExc_RuntimeError }, 134 | "time_ns() returned a non-integer", 135 | )? 136 | // FUTURE: this will break in the year 2262. Fix it before then. 137 | .to_i64()?; 138 | Instant::from_nanos_i64(ns) 139 | .ok_or_raise(unsafe { PyExc_OSError }, "System time out of range") 140 | } 141 | 142 | fn time_ns_rust(&self) -> PyResult { 143 | SystemTime::now() 144 | .duration_since(SystemTime::UNIX_EPOCH) 145 | .ok() 146 | .and_then(Instant::from_duration_since_epoch) 147 | .ok_or_raise(unsafe { PyExc_OSError }, "System time out of range") 148 | } 149 | } 150 | -------------------------------------------------------------------------------- /src/py/misc.rs: -------------------------------------------------------------------------------- 1 | //! Miscellaneous utility functions and constants. 2 | use super::{args::*, base::*, exc::*, refs::*, types::*}; 3 | use core::{ 4 | ffi::{CStr, c_int, c_void}, 5 | ptr::null_mut as NULL, 6 | }; 7 | use pyo3_ffi::*; 8 | 9 | pub(crate) fn none() -> Owned { 10 | // SAFETY: Py_None is a valid pointer 11 | unsafe { PyObj::from_ptr_unchecked(Py_None()) }.newref() 12 | } 13 | 14 | pub(crate) fn identity1(_: PyType, slf: PyObj) -> Owned { 15 | slf.newref() 16 | } 17 | 18 | pub(crate) fn __copy__(_: PyType, slf: PyObj) -> Owned { 19 | slf.newref() 20 | } 21 | 22 | pub(crate) fn __deepcopy__(_: PyType, slf: PyObj, _: PyObj) -> Owned { 23 | slf.newref() 24 | } 25 | 26 | pub(crate) fn import(module: &CStr) -> PyReturn { 27 | unsafe { PyImport_ImportModule(module.as_ptr()) }.rust_owned() 28 | } 29 | 30 | pub(crate) fn __get_pydantic_core_schema__( 31 | cls: HeapType, 32 | _: &[PyObj], 33 | _: &mut IterKwargs, 34 | ) -> PyReturn { 35 | cls.state().get_pydantic_schema.get()?.call1(cls) 36 | } 37 | 38 | pub(crate) fn not_implemented() -> PyReturn { 39 | Ok(Owned::new( 40 | // SAFETY: Py_NotImplemented is always non-null 41 | unsafe { 42 | PyObj::from_ptr_unchecked({ 43 | let ptr = Py_NotImplemented(); 44 | Py_INCREF(ptr); 45 | ptr 46 | }) 47 | }, 48 | )) 49 | } 50 | 51 | /// Pack various types into a byte array. Used for pickling. 52 | macro_rules! pack { 53 | [$x:expr, $($xs:expr),*] => {{ 54 | // OPTIMIZE: use Vec::with_capacity, or a fixed-size array 55 | // since we know the size at compile time 56 | let mut result = Vec::new(); 57 | result.extend_from_slice(&$x.to_le_bytes()); 58 | $( 59 | result.extend_from_slice(&$xs.to_le_bytes()); 60 | )* 61 | result 62 | }} 63 | } 64 | 65 | /// Unpack a single value from a byte array. Used for unpickling. 66 | macro_rules! unpack_one { 67 | ($arr:ident, $t:ty) => {{ 68 | const SIZE: usize = std::mem::size_of::<$t>(); 69 | let data = <$t>::from_le_bytes($arr[..SIZE].try_into().unwrap()); 70 | #[allow(unused_assignments)] 71 | { 72 | $arr = &$arr[SIZE..]; 73 | } 74 | data 75 | }}; 76 | } 77 | 78 | #[derive(Debug)] 79 | pub(crate) struct LazyImport { 80 | module: &'static CStr, 81 | name: &'static CStr, 82 | obj: std::cell::UnsafeCell<*mut PyObject>, 83 | } 84 | 85 | impl LazyImport { 86 | pub(crate) fn new(module: &'static CStr, name: &'static CStr) -> Self { 87 | Self { 88 | module, 89 | name, 90 | obj: std::cell::UnsafeCell::new(NULL()), 91 | } 92 | } 93 | 94 | /// Get the object, importing it if necessary. 95 | pub(crate) fn get(&self) -> PyResult { 96 | unsafe { 97 | let obj = *self.obj.get(); 98 | if obj.is_null() { 99 | let t = import(self.module)?.getattr(self.name)?.py_owned(); 100 | self.obj.get().write(t.as_ptr()); 101 | Ok(t) 102 | } else { 103 | Ok(PyObj::from_ptr_unchecked(obj)) 104 | } 105 | } 106 | } 107 | 108 | /// Ensure Python's GC can traverse this object. 109 | pub(crate) fn traverse(&self, visit: visitproc, arg: *mut c_void) -> TraverseResult { 110 | let obj = unsafe { *self.obj.get() }; 111 | traverse(obj, visit, arg) 112 | } 113 | } 114 | 115 | impl Drop for LazyImport { 116 | fn drop(&mut self) { 117 | unsafe { 118 | let obj = self.obj.get(); 119 | if !(*obj).is_null() { 120 | Py_CLEAR(obj); 121 | } 122 | } 123 | } 124 | } 125 | 126 | // FUTURE: a more efficient way for specific cases? 127 | pub(crate) const fn hashmask(hash: Py_hash_t) -> Py_hash_t { 128 | if hash == -1 { 129 | return -2; 130 | } 131 | hash 132 | } 133 | 134 | /// fast, safe way to combine hash values, from stackoverflow.com/questions/5889238 135 | #[inline] 136 | pub(crate) const fn hash_combine(lhs: Py_hash_t, rhs: Py_hash_t) -> Py_hash_t { 137 | #[cfg(target_pointer_width = "64")] 138 | { 139 | lhs ^ (rhs 140 | .wrapping_add(0x517cc1b727220a95) 141 | .wrapping_add(lhs << 6) 142 | .wrapping_add(lhs >> 2)) 143 | } 144 | #[cfg(target_pointer_width = "32")] 145 | { 146 | lhs ^ (rhs 147 | .wrapping_add(-0x61c88647) 148 | .wrapping_add(lhs << 6) 149 | .wrapping_add(lhs >> 2)) 150 | } 151 | } 152 | 153 | /// Result from traversing a Python object for garbage collection. 154 | pub(crate) type TraverseResult = Result<(), c_int>; 155 | 156 | pub(crate) fn traverse( 157 | target: *mut PyObject, 158 | visit: visitproc, 159 | arg: *mut c_void, 160 | ) -> TraverseResult { 161 | if target.is_null() { 162 | Ok(()) 163 | } else { 164 | match unsafe { (visit)(target, arg) } { 165 | 0 => Ok(()), 166 | n => Err(n), 167 | } 168 | } 169 | } 170 | 171 | #[allow(unused_imports)] 172 | pub(crate) use {pack, unpack_one}; 173 | -------------------------------------------------------------------------------- /.github/workflows/wheels.yml: -------------------------------------------------------------------------------- 1 | name: Build and publish wheels 2 | 3 | on: 4 | push: 5 | tags: 6 | - "*" 7 | workflow_dispatch: 8 | 9 | permissions: 10 | contents: read 11 | 12 | jobs: 13 | check-tag: 14 | name: Check tag correctness 15 | runs-on: ubuntu-latest 16 | if: "startsWith(github.ref, 'refs/tags/')" 17 | steps: 18 | - uses: actions/checkout@v4 19 | - run: | 20 | version=$(grep -E '^version\s*=' pyproject.toml | sed -E 's/^version\s*=\s*"([^"]+)".*/\1/') 21 | 22 | tag="${GITHUB_REF_NAME}" 23 | 24 | echo "Detected version in pyproject.toml: $version" 25 | echo "Detected tag name: $tag" 26 | 27 | # Compare them 28 | if [ "$version" != "$tag" ]; then 29 | echo "❌ Tag ($tag) does not match version in pyproject.toml ($version)" 30 | exit 1 31 | fi 32 | 33 | echo "✅ Tag matches version. Proceeding with release..." 34 | binary: 35 | needs: [check-tag] 36 | name: build on ${{ matrix.os }} (${{ matrix.target }}) (${{ matrix.manylinux || 'auto' }}) 37 | strategy: 38 | fail-fast: false 39 | matrix: 40 | include: 41 | # manylinux targets 42 | - os: linux 43 | target: x86_64 44 | - os: linux 45 | target: x86 46 | - os: linux 47 | target: aarch64 48 | - os: linux 49 | target: armv7 50 | - os: linux 51 | target: ppc64le 52 | - os: linux 53 | target: s390x 54 | 55 | # musllinux targets 56 | - os: linux 57 | target: x86_64 58 | manylinux: musllinux_1_2 59 | - os: linux 60 | target: x86 61 | manylinux: musllinux_1_2 62 | - os: linux 63 | target: aarch64 64 | manylinux: musllinux_1_2 65 | - os: linux 66 | target: armv7 67 | manylinux: musllinux_1_2 68 | 69 | # windows 70 | - os: windows 71 | target: x86_64 72 | - os: windows 73 | python-architecture: x86 74 | target: x86 75 | 76 | # macos 77 | - os: macos 78 | target: x86_64 79 | - os: macos 80 | target: aarch64 81 | 82 | runs-on: ${{ (matrix.os == 'linux' && 'ubuntu') || matrix.os }}-latest 83 | steps: 84 | - uses: actions/checkout@v4 85 | # Older Python versions are missing from 32-bit windows runners 86 | - uses: actions/setup-python@v5 87 | if: ${{ matrix.os == 'windows' && matrix.python-architecture == 'x86' }} 88 | with: 89 | python-version: | 90 | 3.9 91 | 3.10 92 | 3.11 93 | 3.12 94 | architecture: x86 95 | # Some Python versions missing from all windows runners 96 | - uses: actions/setup-python@v5 97 | if: ${{ matrix.os == 'windows'}} 98 | with: 99 | python-version: | 100 | 3.13 101 | 3.13t 102 | 3.14 103 | 3.14t 104 | architecture: "${{ matrix.python-architecture || 'x64' }}" 105 | - name: Generate third-party license information 106 | run: | 107 | cargo install cargo-3pl --version 0.1.3 108 | cargo 3pl > LICENSE-THIRD-PARTY 109 | - name: build wheels 110 | uses: PyO3/maturin-action@v1 111 | with: 112 | target: ${{ matrix.target }} 113 | args: --release --strip --out dist --interpreter '3.9 3.10 3.11 3.12 3.13 3.14 3.13t 3.14t' 114 | manylinux: ${{ matrix.manylinux || 'auto' }} 115 | sccache: "true" 116 | rust-toolchain: "1.87" 117 | - run: pip install -U twine packaging 118 | - run: twine check --strict dist/* 119 | - name: Upload wheels 120 | uses: actions/upload-artifact@v4 121 | with: 122 | name: wheels-binary-${{ matrix.os }}-${{ matrix.target }}-${{ matrix.manylinux || 'auto' }} 123 | path: dist/* 124 | 125 | pure-python: 126 | needs: [check-tag] 127 | runs-on: ubuntu-latest 128 | steps: 129 | - uses: actions/checkout@v4 130 | - run: pip install -U twine packaging build 131 | - name: Build source distribution 132 | run: | 133 | python -m build --sdist --outdir dist 134 | - name: Build pure Python wheel 135 | run: | 136 | python -m build --wheel --outdir dist 137 | env: 138 | WHENEVER_NO_BUILD_RUST_EXT: 1 139 | - run: twine check --strict dist/* 140 | - name: Upload pure Python wheels and sdist 141 | uses: actions/upload-artifact@v4 142 | with: 143 | name: wheels-pure-python 144 | path: dist/* 145 | 146 | release: 147 | name: Release 148 | runs-on: ubuntu-latest 149 | if: "startsWith(github.ref, 'refs/tags/')" 150 | needs: [binary, pure-python] 151 | steps: 152 | - uses: actions/download-artifact@v4 153 | - run: pip install -U twine packaging 154 | - name: Publish to PyPI 155 | run: | 156 | twine upload --non-interactive --disable-progress-bar --skip-existing wheels-*/* 157 | env: 158 | TWINE_USERNAME: __token__ 159 | TWINE_PASSWORD: ${{ secrets.PYPI_API_TOKEN }} 160 | -------------------------------------------------------------------------------- /docs/api.rst: -------------------------------------------------------------------------------- 1 | .. _api: 2 | 3 | 📖 API reference 4 | ================ 5 | 6 | All classes are immutable. 7 | 8 | Datetimes 9 | --------- 10 | 11 | Below is a simplified overview of the datetime classes and how they relate to each other. 12 | 13 | .. image:: api-overview.png 14 | :width: 60% 15 | :align: center 16 | :alt: Alternative text 17 | 18 | Common behavior 19 | ~~~~~~~~~~~~~~~ 20 | 21 | The following base classes encapsulate common behavior. 22 | They are not meant to be used directly. 23 | 24 | .. autoclass:: whenever._BasicConversions 25 | :members: 26 | :inherited-members: 27 | :member-order: bysource 28 | 29 | .. autoclass:: whenever._ExactTime 30 | :members: 31 | :member-order: bysource 32 | :special-members: __eq__, __lt__, __le__, __gt__, __ge__, __sub__ 33 | :show-inheritance: 34 | 35 | .. autoclass:: whenever._LocalTime 36 | :members: 37 | :undoc-members: year, month, day, hour, minute, second, nanosecond 38 | :member-order: bysource 39 | :show-inheritance: 40 | 41 | .. autoclass:: whenever._ExactAndLocalTime 42 | :members: 43 | :member-order: bysource 44 | :show-inheritance: 45 | 46 | Concrete classes 47 | ~~~~~~~~~~~~~~~~ 48 | 49 | .. autoclass:: whenever.Instant 50 | :members: 51 | from_utc, 52 | format_iso, 53 | format_rfc2822, 54 | parse_rfc2822, 55 | add, 56 | subtract, 57 | round, 58 | :special-members: __add__, __sub__ 59 | :member-order: bysource 60 | :show-inheritance: 61 | 62 | .. autoattribute:: MIN 63 | .. autoattribute:: MAX 64 | 65 | .. autoclass:: whenever.PlainDateTime 66 | :members: 67 | format_iso, 68 | assume_utc, 69 | assume_fixed_offset, 70 | assume_tz, 71 | assume_system_tz, 72 | parse_strptime, 73 | difference, 74 | :special-members: __eq__ 75 | :member-order: bysource 76 | :show-inheritance: 77 | 78 | .. autoattribute:: MIN 79 | .. autoattribute:: MAX 80 | 81 | .. autoclass:: whenever.OffsetDateTime 82 | :members: 83 | format_iso, 84 | format_rfc2822, 85 | parse_rfc2822, 86 | parse_strptime, 87 | :member-order: bysource 88 | :show-inheritance: 89 | 90 | .. autoclass:: whenever.ZonedDateTime 91 | :members: 92 | format_iso, 93 | now_in_system_tz, 94 | from_system_tz, 95 | tz, 96 | is_ambiguous, 97 | start_of_day, 98 | day_length, 99 | :member-order: bysource 100 | :show-inheritance: 101 | 102 | Deltas 103 | ------ 104 | 105 | .. autofunction:: whenever.years 106 | .. autofunction:: whenever.months 107 | .. autofunction:: whenever.weeks 108 | .. autofunction:: whenever.days 109 | 110 | .. autofunction:: whenever.hours 111 | .. autofunction:: whenever.minutes 112 | .. autofunction:: whenever.seconds 113 | .. autofunction:: whenever.milliseconds 114 | .. autofunction:: whenever.microseconds 115 | .. autofunction:: whenever.nanoseconds 116 | 117 | .. autoclass:: whenever.TimeDelta 118 | :members: 119 | :special-members: __eq__, __neg__, __add__, __sub__, __mul__, __truediv__, __bool__, __abs__, __gt__ 120 | :member-order: bysource 121 | 122 | .. autoattribute:: MIN 123 | .. autoattribute:: MAX 124 | 125 | .. autoclass:: whenever.DateDelta 126 | :members: 127 | :special-members: __eq__, __neg__, __abs__, __add__, __sub__, __mul__, __bool__ 128 | :member-order: bysource 129 | 130 | .. autoclass:: whenever.DateTimeDelta 131 | :members: 132 | :special-members: __eq__, __neg__, __abs__, __add__, __sub__, __bool__, __mul__ 133 | :member-order: bysource 134 | 135 | .. _date-and-time-api: 136 | 137 | Date and time components 138 | ------------------------ 139 | 140 | .. autoclass:: whenever.Date 141 | :members: 142 | :special-members: __eq__, __lt__, __le__, __gt__, __ge__, __sub__, __add__ 143 | 144 | .. autoattribute:: MIN 145 | .. autoattribute:: MAX 146 | 147 | .. autoclass:: whenever.YearMonth 148 | :members: 149 | :special-members: __eq__, __lt__, __le__, __gt__, __ge__ 150 | 151 | .. autoattribute:: MIN 152 | .. autoattribute:: MAX 153 | 154 | .. autoclass:: whenever.MonthDay 155 | :members: 156 | :special-members: __eq__, __lt__, __le__, __gt__, __ge__ 157 | 158 | .. autoattribute:: MIN 159 | .. autoattribute:: MAX 160 | 161 | .. autoclass:: whenever.Time 162 | :members: 163 | :special-members: __eq__, __lt__, __le__, __gt__, __ge__ 164 | 165 | .. autoattribute:: MIDNIGHT 166 | .. autoattribute:: NOON 167 | .. autoattribute:: MAX 168 | 169 | 170 | Miscellaneous 171 | ------------- 172 | 173 | .. autoenum:: whenever.Weekday 174 | :members: 175 | :member-order: bysource 176 | 177 | .. autodata:: whenever.MONDAY 178 | .. autodata:: whenever.TUESDAY 179 | .. autodata:: whenever.WEDNESDAY 180 | .. autodata:: whenever.THURSDAY 181 | .. autodata:: whenever.FRIDAY 182 | .. autodata:: whenever.SATURDAY 183 | .. autodata:: whenever.SUNDAY 184 | 185 | .. autoexception:: whenever.RepeatedTime 186 | :show-inheritance: 187 | 188 | .. autoexception:: whenever.SkippedTime 189 | :show-inheritance: 190 | 191 | .. autoexception:: whenever.InvalidOffsetError 192 | :show-inheritance: 193 | 194 | .. autoexception:: whenever.TimeZoneNotFoundError 195 | :show-inheritance: 196 | 197 | .. autoclass:: whenever.patch_current_time 198 | 199 | .. data:: whenever.TZPATH 200 | :type: tuple[str, ...] 201 | 202 | The paths in which ``whenever`` will search for timezone data. 203 | By default, this is determined the same way as :data:`zoneinfo.TZPATH`, 204 | although you can override it using :func:`whenever.reset_tzpath` for ``whenever`` specifically. 205 | 206 | .. autofunction:: whenever.clear_tzcache 207 | .. autofunction:: whenever.reset_tzpath 208 | .. autofunction:: whenever.available_timezones 209 | .. autofunction:: whenever.reset_system_tz 210 | -------------------------------------------------------------------------------- /docs/examples.rst: -------------------------------------------------------------------------------- 1 | 🎯 Examples 2 | =========== 3 | 4 | This page contains small, practical examples of using ``whenever``. 5 | For more in-depth information, refer to the :ref:`overview `. 6 | 7 | Get the current time in UTC 8 | --------------------------- 9 | 10 | >>> from whenever import Instant 11 | >>> Instant.now() 12 | Instant("2025-04-19 19:02:56.39569Z") 13 | 14 | Convert UTC to the system timezone 15 | ---------------------------------- 16 | 17 | >>> from whenever import Instant 18 | >>> i = Instant.now() 19 | >>> i.to_system_tz() 20 | ZonedDateTime("2025-04-19 21:02:56.39569+02:00[Europe/Berlin]") 21 | 22 | Convert from one timezone to another 23 | ------------------------------------ 24 | 25 | >>> from whenever import ZonedDateTime 26 | >>> d = ZonedDateTime(2025, 4, 19, hour=15, tz="America/New_York") 27 | >>> d.to_tz("Europe/Berlin") 28 | ZonedDateTime("2025-04-19 21:00:00+02:00[Europe/Berlin]") 29 | 30 | Convert a date to datetime 31 | -------------------------- 32 | 33 | >>> from whenever import Date, Time 34 | >>> date = Date(2023, 10, 1) 35 | >>> date.at(Time(12, 30)) 36 | PlainDateTime("2023-10-01 12:30:00") 37 | 38 | Calculate somebody's age 39 | ------------------------ 40 | 41 | >>> from whenever import Date 42 | >>> birth_date = Date(2023, 11, 2) 43 | >>> age = Date.today_in_system_tz() - birth_date 44 | DateDelta("P1y5m26d") 45 | >>> months, days = age.in_months_days() 46 | (17, 26) 47 | >>> age.in_years_months_days() 48 | (1, 5, 26) 49 | 50 | 51 | Assign a timezone to a datetime 52 | ------------------------------- 53 | 54 | >>> from whenever import PlainDateTime 55 | >>> datetime = PlainDateTime(2023, 10, 1, 12, 30) 56 | >>> datetime.assume_tz("America/New_York") 57 | ZonedDateTime("2023-10-01 12:30:00-04:00[America/New_York]") 58 | 59 | Integrate with the standard library 60 | ----------------------------------- 61 | 62 | >>> import datetime 63 | >>> py_dt = datetime.datetime.now(datetime.UTC) 64 | >>> from whenever import Instant 65 | >>> # create an Instant from any aware datetime 66 | >>> i = Instant.from_py_datetime(py_dt) 67 | Instant("2025-04-19 19:02:56.39569Z") 68 | >>> zdt = i.to_tz("America/New_York") 69 | ZonedDateTime("2025-04-19 15:02:56.39569-04:00[America/New_York]") 70 | >>> # convert back to the standard library 71 | >>> zdt.py_datetime() 72 | datetime.datetime(2025, 4, 19, 15, 2, 56, 395690, tzinfo=ZoneInfo('America/New_York')) 73 | 74 | Parse an ISO8601 datetime string 75 | -------------------------------- 76 | 77 | >>> from whenever import Instant 78 | >>> Instant.parse_iso("2025-04-19T19:02+04:00") 79 | Instant("2025-04-19 15:02:00Z") 80 | 81 | Or, if you want to keep the offset value: 82 | 83 | >>> from whenever import OffsetDateTime 84 | >>> OffsetDateTime.parse_iso("2025-04-19T19:02+04:00") 85 | OffsetDateTime("2025-04-19 19:02:00+04:00") 86 | 87 | Determine the start of the hour 88 | ------------------------------- 89 | 90 | >>> d = ZonedDateTime.now("America/New_York") 91 | ZonedDateTime("2025-04-19 15:46:41-04:00[America/New_York]") 92 | >>> d.round("hour", mode="floor") 93 | ZonedDateTime("2025-04-19 15:00:00-04:00[America/New_York]") 94 | 95 | The :meth:`~whenever._LocalTime.round` method can be used for so much more! 96 | See its documentation for more details. 97 | 98 | Get the current unix timestamp 99 | ------------------------------ 100 | 101 | >>> from whenever import Instant 102 | >>> i = Instant.now() 103 | >>> i.timestamp() 104 | 1745090505 105 | 106 | Note that this is always in whole seconds. 107 | If you need additional precision: 108 | 109 | >>> i.timestamp_millis() 110 | 1745090505629 111 | >>> i.timestamp_nanos() 112 | 1745090505629346833 113 | 114 | Get a date and time from a timestamp 115 | ------------------------------------ 116 | 117 | >>> from whenever import ZonedDateTime 118 | >>> ZonedDateTime.from_timestamp(1745090505, tz="America/New_York") 119 | ZonedDateTime("2025-04-19 15:21:45-04:00[America/New_York]") 120 | 121 | Find the duration between two datetimes 122 | --------------------------------------- 123 | 124 | >>> from whenever import ZonedDateTime 125 | >>> d = ZonedDateTime(2025, 1, 3, hour=15, tz="America/New_York") 126 | >>> d2 = ZonedDateTime(2025, 1, 5, hour=8, minute=24, tz="Europe/Paris") 127 | >>> d2 - d 128 | TimeDelta("PT35h24m") 129 | 130 | Move a date by six months 131 | ------------------------- 132 | 133 | >>> from whenever import Date 134 | >>> date = Date(2023, 10, 31) 135 | >>> date.add(months=6) 136 | Date("2024-04-30") 137 | 138 | Discard fractional seconds 139 | -------------------------- 140 | 141 | >>> from whenever import Instant 142 | >>> i = Instant.now() 143 | Instant("2025-04-19 19:02:56.39569Z") 144 | >>> i.round() 145 | Instant("2025-04-19 19:02:56Z") 146 | 147 | Use the arguments of :meth:`~whenever.Instant.round` to customize the rounding behavior. 148 | 149 | Handling ambiguous datetimes 150 | ---------------------------- 151 | 152 | Due to daylight saving time, some date and time values don't exist, 153 | or occur twice in a given timezone. 154 | In the example below, the clock was set forward by one hour at 2:00 AM, 155 | so the time 2:30 AM doesn't exist. 156 | 157 | >>> from whenever import ZonedDateTime 158 | >>> # set up the date and time for the example 159 | >>> dt = PlainDateTime(2023, 2, 26, hour=2, minute=30) 160 | 161 | The default behavior (take the first offset) is consistent with other 162 | modern libraries and industry standards: 163 | 164 | >>> zoned = dt.assume_tz("Europe/Berlin") 165 | ZonedDateTime("2023-02-26 03:30:00+02:00[Europe/Berlin]") 166 | 167 | But it's also possible to "refuse to guess" and choose the "earlier" 168 | or "later" occurrence explicitly: 169 | 170 | >>> zoned = dt.assume_tz("Europe/Berlin", disambiguate="earlier") 171 | ZonedDateTime("2023-02-26 01:30:00+02:00[Europe/Berlin]") 172 | 173 | Or, you can even reject ambiguous datetimes altogether: 174 | 175 | >>> zoned = dt.assume_tz("Europe/Berlin", disambiguate="raise") 176 | -------------------------------------------------------------------------------- /src/py/string.rs: -------------------------------------------------------------------------------- 1 | //! Functionality for working with Python's `str` and `bytes` objects. 2 | use crate::common::fmt; 3 | 4 | use super::{base::*, exc::*, refs::*}; 5 | use pyo3_ffi::*; 6 | use std::{ffi::c_uint, ptr::copy_nonoverlapping}; 7 | 8 | #[derive(Debug, Clone, Copy)] 9 | pub(crate) struct PyStr { 10 | obj: PyObj, 11 | } 12 | 13 | impl PyStr { 14 | pub(crate) fn as_utf8(&self) -> PyResult<&[u8]> { 15 | let mut size = 0; 16 | let p = unsafe { PyUnicode_AsUTF8AndSize(self.as_ptr(), &mut size) }; 17 | if p.is_null() { 18 | return Err(PyErrMarker()); 19 | }; 20 | Ok(unsafe { std::slice::from_raw_parts(p.cast::(), size as usize) }) 21 | } 22 | 23 | pub(crate) fn as_str(&self) -> PyResult<&str> { 24 | let mut size = 0; 25 | let p = unsafe { PyUnicode_AsUTF8AndSize(self.as_ptr(), &mut size) }; 26 | if p.is_null() { 27 | return Err(PyErrMarker()); 28 | }; 29 | Ok(unsafe { 30 | std::str::from_utf8_unchecked(std::slice::from_raw_parts(p.cast::(), size as usize)) 31 | }) 32 | } 33 | } 34 | 35 | impl PyBase for PyStr { 36 | fn as_py_obj(&self) -> PyObj { 37 | self.obj 38 | } 39 | } 40 | 41 | impl FromPy for PyStr { 42 | unsafe fn from_ptr_unchecked(ptr: *mut PyObject) -> Self { 43 | Self { 44 | obj: unsafe { PyObj::from_ptr_unchecked(ptr) }, 45 | } 46 | } 47 | } 48 | 49 | impl PyStaticType for PyStr { 50 | fn isinstance_exact(obj: impl PyBase) -> bool { 51 | unsafe { PyUnicode_CheckExact(obj.as_ptr()) != 0 } 52 | } 53 | 54 | fn isinstance(obj: impl PyBase) -> bool { 55 | unsafe { PyUnicode_Check(obj.as_ptr()) != 0 } 56 | } 57 | } 58 | 59 | #[derive(Debug, Clone, Copy, PartialEq, Eq)] 60 | pub(crate) struct PyBytes { 61 | obj: PyObj, 62 | } 63 | 64 | impl PyBase for PyBytes { 65 | fn as_py_obj(&self) -> PyObj { 66 | self.obj 67 | } 68 | } 69 | 70 | impl FromPy for PyBytes { 71 | unsafe fn from_ptr_unchecked(ptr: *mut PyObject) -> Self { 72 | Self { 73 | obj: unsafe { PyObj::from_ptr_unchecked(ptr) }, 74 | } 75 | } 76 | } 77 | 78 | impl PyStaticType for PyBytes { 79 | fn isinstance_exact(obj: impl PyBase) -> bool { 80 | unsafe { PyBytes_CheckExact(obj.as_ptr()) != 0 } 81 | } 82 | 83 | fn isinstance(obj: impl PyBase) -> bool { 84 | unsafe { PyBytes_Check(obj.as_ptr()) != 0 } 85 | } 86 | } 87 | 88 | impl PyBytes { 89 | pub(crate) fn as_bytes(&self) -> PyResult<&[u8]> { 90 | // FUTURE: is there a way to use unchecked versions of 91 | // the C API: PyBytes_AS_STRING, PyBytes_GET_SIZE? 92 | let p = unsafe { PyBytes_AsString(self.as_ptr()) }; 93 | if p.is_null() { 94 | return Err(PyErrMarker()); 95 | }; 96 | Ok(unsafe { 97 | std::slice::from_raw_parts(p.cast::(), PyBytes_Size(self.as_ptr()) as usize) 98 | }) 99 | } 100 | } 101 | 102 | impl ToPy for String { 103 | fn to_py(self) -> PyReturn { 104 | unsafe { PyUnicode_FromStringAndSize(self.as_ptr().cast(), self.len() as _) }.rust_owned() 105 | } 106 | } 107 | 108 | impl ToPy for &str { 109 | fn to_py(self) -> PyReturn { 110 | unsafe { PyUnicode_FromStringAndSize(self.as_ptr().cast(), self.len() as _) }.rust_owned() 111 | } 112 | } 113 | 114 | impl ToPy for &[u8] { 115 | fn to_py(self) -> PyReturn { 116 | unsafe { PyBytes_FromStringAndSize(self.as_ptr().cast(), self.len() as _) }.rust_owned() 117 | } 118 | } 119 | 120 | impl ToPy for [u8; N] { 121 | fn to_py(self) -> PyReturn { 122 | unsafe { PyBytes_FromStringAndSize(self.as_ptr().cast(), N as _) }.rust_owned() 123 | } 124 | } 125 | 126 | /// Helper for building a Python `str` object incrementally, without 127 | /// having to allocate a Rust `String` first. 128 | /// 129 | /// SAFETY: This only supports ASCII strings (i.e. code points 0..=127). 130 | /// There is no bounds checking, so the caller must ensure that only 131 | /// valid ASCII characters are written, and that the total length does 132 | /// not exceed the length specified at creation. 133 | #[derive(Debug)] 134 | pub(crate) struct PyAsciiStrBuilder { 135 | obj: Owned, // the PyUnicode object being built. Owned ensures cleanup. 136 | index: Py_ssize_t, // current write index 137 | data: *mut u8, // PyUnicode_DATA() pointer 138 | #[cfg(debug_assertions)] 139 | _len: Py_ssize_t, // length of the string (for debug assertions) 140 | } 141 | 142 | const ASCII_STR_KIND: c_uint = 1; 143 | 144 | impl PyAsciiStrBuilder { 145 | /// Create a new builder for a string of the given length. 146 | fn new(len: usize) -> PyResult { 147 | let obj = unsafe { PyUnicode_New(len as _, 127) }.rust_owned()?; 148 | debug_assert!(unsafe { PyUnicode_KIND(obj.as_ptr()) == ASCII_STR_KIND }); 149 | Ok(Self { 150 | data: unsafe { PyUnicode_DATA(obj.as_ptr()).cast() }, 151 | index: 0, 152 | obj, 153 | #[cfg(debug_assertions)] 154 | _len: len as Py_ssize_t, 155 | }) 156 | } 157 | 158 | /// Finalize the builder and return the built `str` object. 159 | fn finish(self) -> Owned { 160 | #[cfg(debug_assertions)] 161 | assert_eq!(self.index, self._len); // DEBUG: full length written 162 | self.obj 163 | } 164 | 165 | pub(crate) fn format(c: impl fmt::Chunk) -> PyResult> { 166 | let mut sink = Self::new(c.len())?; 167 | c.write(&mut sink); 168 | Ok(sink.finish()) 169 | } 170 | } 171 | 172 | impl fmt::Sink for PyAsciiStrBuilder { 173 | #[inline] 174 | fn write_byte(&mut self, b: u8) { 175 | debug_assert!(b.is_ascii()); 176 | #[cfg(debug_assertions)] 177 | assert!(self.index < self._len); 178 | // Essentially the PyUnicode_WRITE() macro from the CPython API 179 | unsafe { *self.data.offset(self.index) = b }; 180 | self.index += 1; 181 | } 182 | 183 | #[inline] 184 | fn write(&mut self, s: &[u8]) { 185 | debug_assert!(s.is_ascii()); 186 | #[cfg(debug_assertions)] 187 | assert!(self.index + s.len() as Py_ssize_t <= self._len); 188 | unsafe { copy_nonoverlapping(s.as_ptr(), self.data.offset(self.index), s.len()) }; 189 | self.index += s.len() as Py_ssize_t; 190 | } 191 | } 192 | -------------------------------------------------------------------------------- /src/py/types.rs: -------------------------------------------------------------------------------- 1 | //! Functionality related to Python type objects 2 | use super::{base::*, exc::*, module::*, refs::*}; 3 | use crate::pymodule::State; 4 | use core::{ 5 | ffi::CStr, 6 | mem::{self, MaybeUninit}, 7 | ptr::NonNull, 8 | }; 9 | use pyo3_ffi::*; 10 | 11 | /// Wrapper around PyTypeObject. 12 | #[derive(Debug, Clone, Copy, PartialEq, Eq)] 13 | pub(crate) struct PyType { 14 | obj: PyObj, 15 | } 16 | 17 | impl PyBase for PyType { 18 | fn as_py_obj(&self) -> PyObj { 19 | self.obj 20 | } 21 | } 22 | 23 | impl FromPy for PyType { 24 | unsafe fn from_ptr_unchecked(ptr: *mut PyObject) -> Self { 25 | Self { 26 | obj: unsafe { PyObj::from_ptr_unchecked(ptr.cast()) }, 27 | } 28 | } 29 | } 30 | 31 | impl PyStaticType for PyType { 32 | fn isinstance_exact(obj: impl PyBase) -> bool { 33 | unsafe { PyType_CheckExact(obj.as_ptr()) != 0 } 34 | } 35 | fn isinstance(obj: impl PyBase) -> bool { 36 | unsafe { PyType_Check(obj.as_ptr()) != 0 } 37 | } 38 | } 39 | 40 | impl PyType { 41 | /// Get the module state if both types are from the whenever module. 42 | pub(crate) fn same_module(&self, other: PyType) -> Option<&State> { 43 | let Some(mod_a) = NonNull::new(unsafe { PyType_GetModule(self.as_ptr().cast()) }) else { 44 | unsafe { PyErr_Clear() }; 45 | return None; 46 | }; 47 | let Some(mod_b) = NonNull::new(unsafe { PyType_GetModule(other.as_ptr().cast()) }) else { 48 | unsafe { PyErr_Clear() }; 49 | return None; 50 | }; 51 | if mod_a == mod_b { 52 | // SAFETY: we only use this function after module initialization 53 | unsafe { 54 | PyModule::from_ptr_unchecked(mod_a.as_ptr()) 55 | .state() 56 | .assume_init_ref() 57 | .as_ref() 58 | } 59 | } else { 60 | None 61 | } 62 | } 63 | 64 | /// Associate the type with the given Rust type. 65 | pub(crate) unsafe fn link_type(self) -> HeapType { 66 | // SAFETY: we assume the pointer is valid and points to a PyType object 67 | unsafe { HeapType::from_ptr_unchecked(self.as_ptr()) } 68 | } 69 | } 70 | 71 | impl std::fmt::Display for PyType { 72 | fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 73 | self.write_repr(f) 74 | } 75 | } 76 | 77 | /// A PyTypeObject that is linked to a Rust struct in whenever. 78 | #[derive(Debug, Copy, Clone, PartialEq, Eq)] 79 | pub(crate) struct HeapType { 80 | type_py: PyType, 81 | type_rust: std::marker::PhantomData, 82 | } 83 | 84 | impl HeapType { 85 | /// Get the module state 86 | pub(crate) fn state<'a>(&self) -> &'a State { 87 | // SAFETY: the type pointer is valid, and the retrieved module 88 | // state is valid once the Python module is initialized. 89 | unsafe { 90 | PyType_GetModuleState(self.type_py.as_ptr().cast()) 91 | .cast::>>() 92 | .as_ref() 93 | .unwrap() 94 | .assume_init_ref() 95 | .as_ref() 96 | .unwrap() 97 | } 98 | } 99 | 100 | pub(crate) fn inner(&self) -> PyType { 101 | self.type_py 102 | } 103 | } 104 | 105 | impl PyBase for HeapType { 106 | fn as_py_obj(&self) -> PyObj { 107 | self.type_py.as_py_obj() 108 | } 109 | } 110 | 111 | impl FromPy for HeapType { 112 | unsafe fn from_ptr_unchecked(ptr: *mut PyObject) -> Self { 113 | Self { 114 | type_py: unsafe { PyType::from_ptr_unchecked(ptr) }, 115 | type_rust: std::marker::PhantomData, 116 | } 117 | } 118 | } 119 | 120 | impl From> for PyType { 121 | fn from(t: HeapType) -> Self { 122 | t.type_py 123 | } 124 | } 125 | 126 | /// A trait for Rust structs that can be wrapped in a PyObject. 127 | pub(crate) trait PyWrapped: FromPy { 128 | #[inline] 129 | unsafe fn from_obj(obj: *mut PyObject) -> Self { 130 | unsafe { (*obj.cast::>()).data } 131 | } 132 | } 133 | 134 | // Not all PyWrapped objects can be allocated simply. 135 | // For example ZonedDateTime is a bit more complex since it needs to 136 | // refcount timezones. 137 | pub(crate) trait PySimpleAlloc: PyWrapped { 138 | #[inline] 139 | fn to_obj(self, type_: HeapType) -> PyReturn { 140 | generic_alloc(type_.type_py, self) 141 | } 142 | } 143 | 144 | impl PyWrapped for T {} 145 | 146 | impl FromPy for T { 147 | unsafe fn from_ptr_unchecked(ptr: *mut PyObject) -> T { 148 | unsafe { T::from_obj(ptr) } 149 | } 150 | } 151 | 152 | /// The shape of PyObjects that wrap a `whenever` Rust type. 153 | #[repr(C)] 154 | struct PyWrap { 155 | _ob_base: PyObject, 156 | data: T, 157 | } 158 | 159 | pub(crate) const fn type_spec( 160 | name: &CStr, 161 | slots: &'static [PyType_Slot], 162 | ) -> PyType_Spec { 163 | PyType_Spec { 164 | name: name.as_ptr().cast(), 165 | basicsize: mem::size_of::>() as _, 166 | itemsize: 0, 167 | // NOTE: IMMUTABLETYPE flag is required to prevent additional refcycles 168 | // between the class and the instance. 169 | // This allows us to keep our types GC-free. 170 | #[cfg(Py_3_10)] 171 | flags: (Py_TPFLAGS_DEFAULT | Py_TPFLAGS_IMMUTABLETYPE) as _, 172 | // XXX: implement a way to prevent refcycles on Python 3.9 173 | // without Py_TPFLAGS_IMMUTABLETYPE. 174 | // Not a pressing concern, because this only will be triggered 175 | // if users themselves decide to add instances to the class 176 | // namespace. 177 | // Even so, this will just result in a minor memory leak 178 | // preventing the module from being GC'ed, 179 | // since subinterpreters aren't a concern. 180 | #[cfg(not(Py_3_10))] 181 | flags: Py_TPFLAGS_DEFAULT as _, 182 | slots: slots.as_ptr().cast_mut(), 183 | } 184 | } 185 | 186 | pub(crate) extern "C" fn generic_dealloc(slf: PyObj) { 187 | let cls = slf.type_().as_ptr().cast::(); 188 | unsafe { 189 | let tp_free = PyType_GetSlot(cls, Py_tp_free); 190 | debug_assert_ne!(tp_free, core::ptr::null_mut()); 191 | let tp_free: freefunc = std::mem::transmute(tp_free); 192 | tp_free(slf.as_ptr().cast()); 193 | Py_DECREF(cls.cast()); 194 | } 195 | } 196 | 197 | #[inline] 198 | pub(crate) fn generic_alloc(type_: PyType, d: T) -> PyReturn { 199 | let type_ptr = type_.as_ptr().cast::(); 200 | unsafe { 201 | let slf = (*type_ptr).tp_alloc.unwrap()(type_ptr, 0).cast::>(); 202 | match slf.cast::().as_mut() { 203 | Some(r) => { 204 | (&raw mut (*slf).data).write(d); 205 | Ok(Owned::new(PyObj::from_ptr_unchecked(r))) 206 | } 207 | None => Err(PyErrMarker()), 208 | } 209 | } 210 | } 211 | -------------------------------------------------------------------------------- /tests/test_year_month.py: -------------------------------------------------------------------------------- 1 | import pickle 2 | import re 3 | from copy import copy, deepcopy 4 | 5 | import pytest 6 | 7 | from whenever import Date, YearMonth 8 | 9 | from .common import AlwaysEqual, AlwaysLarger, AlwaysSmaller, NeverEqual 10 | 11 | 12 | class TestInit: 13 | 14 | def test_valid(self): 15 | assert YearMonth(2021, 12) is not None 16 | assert YearMonth(1, 1) is not None 17 | assert YearMonth(9999, 12) is not None 18 | assert YearMonth(year=2002, month=2) is not None 19 | 20 | @pytest.mark.parametrize( 21 | "year, month", 22 | [ 23 | (2021, 13), 24 | (3000, 0), 25 | (2000, -1), 26 | (0, 3), 27 | (10_000, 3), 28 | ], 29 | ) 30 | def test_invalid_combinations(self, year, month): 31 | with pytest.raises(ValueError): 32 | YearMonth(year, month) 33 | 34 | def test_invalid(self): 35 | with pytest.raises(TypeError): 36 | YearMonth(2000) # type: ignore[call-overload] 37 | 38 | with pytest.raises(TypeError): 39 | YearMonth("2001", "SEP") # type: ignore[call-overload] 40 | 41 | with pytest.raises(TypeError): 42 | YearMonth() # type: ignore[call-overload] 43 | 44 | def test_iso(self): 45 | assert YearMonth("2021-12") == YearMonth(2021, 12) 46 | 47 | 48 | def test_properties(): 49 | ym = YearMonth(2021, 12) 50 | assert ym.year == 2021 51 | assert ym.month == 12 52 | 53 | 54 | def test_eq(): 55 | ym = YearMonth(2021, 12) 56 | same = YearMonth(2021, 12) 57 | different = YearMonth(2021, 11) 58 | 59 | assert ym == same 60 | assert not ym == different 61 | assert not ym == NeverEqual() 62 | assert ym == AlwaysEqual() 63 | 64 | assert not ym != same 65 | assert ym != different 66 | assert ym != NeverEqual() 67 | assert not ym != AlwaysEqual() 68 | assert ym != None # noqa: E711 69 | assert None != ym # noqa: E711 70 | assert not ym == None # noqa: E711 71 | assert not None == ym # noqa: E711 72 | 73 | assert hash(ym) == hash(same) 74 | 75 | 76 | def test_comparison(): 77 | ym = YearMonth(2021, 5) 78 | same = YearMonth(2021, 5) 79 | bigger = YearMonth(2022, 2) 80 | smaller = YearMonth(2020, 12) 81 | 82 | assert ym <= same 83 | assert ym <= bigger 84 | assert not ym <= smaller 85 | assert ym <= AlwaysLarger() 86 | assert not ym <= AlwaysSmaller() 87 | 88 | assert not ym < same 89 | assert ym < bigger 90 | assert not ym < smaller 91 | assert ym < AlwaysLarger() 92 | assert not ym < AlwaysSmaller() 93 | 94 | assert ym >= same 95 | assert not ym >= bigger 96 | assert ym >= smaller 97 | assert not ym >= AlwaysLarger() 98 | assert ym >= AlwaysSmaller() 99 | 100 | assert not ym > same 101 | assert not ym > bigger 102 | assert ym > smaller 103 | assert not ym > AlwaysLarger() 104 | assert ym > AlwaysSmaller() 105 | 106 | 107 | def test_format_iso(): 108 | assert YearMonth(2021, 12).format_iso() == "2021-12" 109 | assert YearMonth(2, 1).format_iso() == "0002-01" 110 | 111 | 112 | def test_str(): 113 | assert str(YearMonth(2021, 12)) == "2021-12" 114 | assert str(YearMonth(2, 1)) == "0002-01" 115 | 116 | 117 | def test_repr(): 118 | assert repr(YearMonth(2021, 12)) == 'YearMonth("2021-12")' 119 | assert repr(YearMonth(2, 1)) == 'YearMonth("0002-01")' 120 | 121 | 122 | class TestParseIso: 123 | 124 | @pytest.mark.parametrize( 125 | "s, expected", 126 | [ 127 | ("2021-01", YearMonth(2021, 1)), 128 | ("0014-12", YearMonth(14, 12)), 129 | ("001412", YearMonth(14, 12)), 130 | ], 131 | ) 132 | def test_valid(self, s, expected): 133 | assert YearMonth.parse_iso(s) == expected 134 | 135 | @pytest.mark.parametrize( 136 | "s", 137 | [ 138 | "202A-01", # non-digit 139 | "2021-01T03:04:05", # with a time 140 | "2021-01-02", # with a day 141 | "2021-1", # no padding 142 | "21-01", # no padding 143 | "2020-123", # ordinal date 144 | "2020-003", # ordinal date 145 | "2020-W12", # week date 146 | "20-12", # two-digit year 147 | "120-12", # three-digit year 148 | "-012-12", # negative year 149 | "312🧨-12", # non-ASCII 150 | "202𝟙-11", # non-ascii 151 | ], 152 | ) 153 | def test_invalid(self, s): 154 | with pytest.raises( 155 | ValueError, 156 | match=r"Invalid format.*" + re.escape(repr(s)), 157 | ): 158 | YearMonth.parse_iso(s) 159 | 160 | def test_no_string(self): 161 | with pytest.raises((TypeError, AttributeError), match="(int|str)"): 162 | YearMonth.parse_iso(20210102) # type: ignore[arg-type] 163 | 164 | 165 | def test_replace(): 166 | ym = YearMonth(2021, 1) 167 | assert ym.replace(year=2022) == YearMonth(2022, 1) 168 | assert ym.replace(month=2) == YearMonth(2021, 2) 169 | assert ym == YearMonth(2021, 1) # original is unchanged 170 | 171 | with pytest.raises(TypeError): 172 | ym.replace(3) # type: ignore[misc] 173 | 174 | with pytest.raises(TypeError, match="foo"): 175 | ym.replace(foo=3) # type: ignore[call-arg] 176 | 177 | with pytest.raises(TypeError, match="day"): 178 | ym.replace(day=3) # type: ignore[call-arg] 179 | 180 | with pytest.raises(TypeError, match="foo"): 181 | ym.replace(foo="blabla") # type: ignore[call-arg] 182 | 183 | with pytest.raises(ValueError, match="(date|year)"): 184 | ym.replace(year=10_000) 185 | 186 | 187 | def test_on_day(): 188 | ym = YearMonth(2021, 1) 189 | assert ym.on_day(3) == Date(2021, 1, 3) 190 | assert ym.on_day(31) == Date(2021, 1, 31) 191 | 192 | with pytest.raises(ValueError): 193 | ym.on_day(0) 194 | 195 | with pytest.raises(ValueError): 196 | ym.on_day(-9) 197 | 198 | with pytest.raises(ValueError): 199 | ym.on_day(10_000) 200 | 201 | ym2 = YearMonth(2009, 2) 202 | assert ym2.on_day(28) == Date(2009, 2, 28) 203 | with pytest.raises(ValueError): 204 | ym2.on_day(29) 205 | 206 | ym3 = YearMonth(2016, 2) 207 | assert ym3.on_day(29) == Date(2016, 2, 29) 208 | 209 | 210 | def test_copy(): 211 | ym = YearMonth(2021, 1) 212 | assert copy(ym) is ym 213 | assert deepcopy(ym) is ym 214 | 215 | 216 | def test_singletons(): 217 | assert YearMonth.MIN == YearMonth(1, 1) 218 | assert YearMonth.MAX == YearMonth(9999, 12) 219 | 220 | 221 | def test_pickling(): 222 | d = YearMonth(2021, 1) 223 | dumped = pickle.dumps(d) 224 | assert pickle.loads(dumped) == d 225 | 226 | 227 | def test_unpickle_compatibility(): 228 | dumped = ( 229 | b"\x80\x04\x95$\x00\x00\x00\x00\x00\x00\x00\x8c\x08whenever\x94\x8c\t_unpkl_y" 230 | b"m\x94\x93\x94C\x03\xe5\x07\x01\x94\x85\x94R\x94." 231 | ) 232 | assert pickle.loads(dumped) == YearMonth(2021, 1) 233 | 234 | 235 | def test_cannot_subclass(): 236 | with pytest.raises(TypeError): 237 | 238 | class SubclassDate(YearMonth): # type: ignore[misc] 239 | pass 240 | -------------------------------------------------------------------------------- /src/py/base.rs: -------------------------------------------------------------------------------- 1 | //! Basic types and traits for PyObject and its subtypes. 2 | use super::{exc::*, refs::*, string::*, tuple::*, types::*}; 3 | use core::{ffi::CStr, fmt::Debug, ptr::NonNull, ptr::null_mut as NULL}; 4 | use pyo3_ffi::*; 5 | 6 | pub(crate) trait FromPy: Sized + Copy { 7 | unsafe fn from_ptr_unchecked(ptr: *mut PyObject) -> Self; 8 | } 9 | 10 | pub(crate) trait ToPy: Sized { 11 | fn to_py(self) -> PyReturn; 12 | } 13 | 14 | pub(crate) trait PyStaticType: PyBase { 15 | fn isinstance_exact(obj: impl PyBase) -> bool; 16 | fn isinstance(obj: impl PyBase) -> bool; 17 | } 18 | 19 | /// A minimal wrapper for the PyObject pointer. 20 | /// Transparent to PyObject to allow casting to/from PyObject. 21 | #[repr(transparent)] 22 | #[derive(Debug, Clone, Copy, PartialEq, Eq)] 23 | pub(crate) struct PyObj { 24 | inner: NonNull, 25 | } 26 | 27 | impl PyObj { 28 | pub(crate) fn new(ptr: *mut PyObject) -> PyResult { 29 | match NonNull::new(ptr) { 30 | Some(x) => Ok(Self { inner: x }), 31 | None => Err(PyErrMarker()), 32 | } 33 | } 34 | 35 | pub(crate) fn wrap(inner: NonNull) -> Self { 36 | Self { inner } 37 | } 38 | 39 | /// The PyType of this object. 40 | pub(crate) fn type_(&self) -> PyType { 41 | unsafe { PyType::from_ptr_unchecked(Py_TYPE(self.inner.as_ptr()).cast()) } 42 | } 43 | 44 | /// Convert into anything that converts from PyObject. 45 | /// Useful for passing Pyobjects into functions that may convert them. 46 | pub(crate) unsafe fn into_unchecked(self) -> T { 47 | unsafe { T::from_ptr_unchecked(self.as_ptr()) } 48 | } 49 | 50 | pub(crate) unsafe fn assume_heaptype(&self) -> (HeapType, T) { 51 | ( 52 | unsafe { HeapType::from_ptr_unchecked(self.type_().as_ptr()) }, 53 | unsafe { T::from_obj(self.inner.as_ptr()) }, 54 | ) 55 | } 56 | 57 | pub(crate) fn extract(&self, t: HeapType) -> Option { 58 | (self.type_() == t.inner()).then( 59 | // SAFETY: we've just checked the type, so this is safe 60 | || unsafe { T::from_obj(self.inner.as_ptr()) }, 61 | ) 62 | } 63 | 64 | /// Downcast to a specific type *exactly*. Cannot be used for heap types, 65 | /// use `extract` instead. 66 | pub(crate) fn cast_exact(self) -> Option { 67 | T::isinstance_exact(self) 68 | .then_some(unsafe { T::from_ptr_unchecked(self.as_py_obj().inner.as_ptr()) }) 69 | } 70 | 71 | /// Like `cast`, but allows subclasses. 72 | pub(crate) fn cast_allow_subclass(self) -> Option { 73 | T::isinstance(self) 74 | .then_some(unsafe { T::from_ptr_unchecked(self.as_py_obj().inner.as_ptr()) }) 75 | } 76 | 77 | /// Like `cast`, but does not check the type. 78 | pub(crate) unsafe fn cast_unchecked(self) -> T { 79 | unsafe { T::from_ptr_unchecked(self.as_ptr()) } 80 | } 81 | 82 | pub(crate) fn is_none(&self) -> bool { 83 | self.as_ptr() == unsafe { Py_None() } 84 | } 85 | } 86 | 87 | impl PyBase for PyObj { 88 | fn as_py_obj(&self) -> PyObj { 89 | *self 90 | } 91 | } 92 | 93 | impl FromPy for PyObj { 94 | unsafe fn from_ptr_unchecked(ptr: *mut PyObject) -> Self { 95 | Self { 96 | inner: unsafe { NonNull::new_unchecked(ptr) }, 97 | } 98 | } 99 | } 100 | 101 | impl PyStaticType for PyObj { 102 | fn isinstance_exact(_: impl PyBase) -> bool { 103 | true 104 | } 105 | 106 | fn isinstance(_: impl PyBase) -> bool { 107 | true 108 | } 109 | } 110 | 111 | /// A trait for all PyObject and subtypes. 112 | pub(crate) trait PyBase: FromPy { 113 | fn as_py_obj(&self) -> PyObj; 114 | 115 | /// Create a new, owned, reference to this object. 116 | fn newref(self) -> Owned { 117 | unsafe { Py_INCREF(self.as_py_obj().as_ptr()) } 118 | Owned::new(self) 119 | } 120 | 121 | /// Get the PyObject pointer. 122 | fn as_ptr(&self) -> *mut PyObject { 123 | self.as_py_obj().inner.as_ptr() 124 | } 125 | 126 | /// Write the repr of the object to the given formatter. 127 | fn write_repr(&self, f: &mut T) -> std::fmt::Result { 128 | let Ok(repr_obj) = unsafe { PyObject_Repr(self.as_ptr()) }.rust_owned() else { 129 | // i.e. repr() raised an exception 130 | unsafe { PyErr_Clear() }; 131 | return f.write_str(""); 132 | }; 133 | let Some(py_str) = repr_obj.cast_exact::() else { 134 | // i.e. repr() didn't return a string 135 | return f.write_str(""); 136 | }; 137 | let Ok(utf8) = py_str.as_utf8() else { 138 | // i.e. repr() returned a non-UTF-8 string 139 | unsafe { PyErr_Clear() }; 140 | return f.write_str(""); 141 | }; 142 | // SAFETY: Python emits valid UTF-8 strings 143 | f.write_str(unsafe { std::str::from_utf8_unchecked(utf8) }) 144 | } 145 | 146 | /// Call `getattr()` on the object 147 | fn getattr(&self, name: &CStr) -> PyReturn { 148 | unsafe { PyObject_GetAttrString(self.as_ptr(), name.as_ptr()) }.rust_owned() 149 | } 150 | 151 | /// Call __getitem__ of the object 152 | fn getitem(&self, key: PyObj) -> PyReturn { 153 | unsafe { PyObject_GetItem(self.as_ptr(), key.as_ptr()) }.rust_owned() 154 | } 155 | 156 | /// Get the attribute of the object. 157 | fn setattr(&self, name: &CStr, v: PyObj) -> PyResult<()> { 158 | if unsafe { PyObject_SetAttrString(self.as_ptr(), name.as_ptr(), v.as_ptr()) } == 0 { 159 | Ok(()) 160 | } else { 161 | Err(PyErrMarker()) 162 | } 163 | } 164 | 165 | /// Call the object with one argument. 166 | fn call1(&self, arg: impl PyBase) -> PyReturn { 167 | unsafe { PyObject_CallOneArg(self.as_ptr(), arg.as_ptr()) }.rust_owned() 168 | } 169 | 170 | /// Call the object with no arguments. 171 | fn call0(&self) -> PyReturn { 172 | unsafe { PyObject_CallNoArgs(self.as_ptr()) }.rust_owned() 173 | } 174 | 175 | /// Call the object with a tuple of arguments. 176 | fn call(&self, args: PyTuple) -> PyReturn { 177 | // OPTIMIZE: use vectorcall? 178 | unsafe { PyObject_Call(self.as_ptr(), args.as_ptr(), NULL()) }.rust_owned() 179 | } 180 | 181 | /// Determine if the object is equal to another object, according to Python's 182 | /// `__eq__` method. 183 | fn py_eq(&self, other: impl PyBase) -> PyResult { 184 | // SAFETY: calling CPython API with valid arguments 185 | match unsafe { PyObject_RichCompareBool(self.as_ptr(), other.as_ptr(), Py_EQ) } { 186 | 1 => Ok(true), 187 | 0 => Ok(false), 188 | _ => Err(PyErrMarker()), 189 | } 190 | } 191 | 192 | /// Determine if the object is *exactly equal* to `True`. 193 | fn is_true(&self) -> bool { 194 | unsafe { self.as_ptr() == Py_True() } 195 | } 196 | 197 | fn is_false(&self) -> bool { 198 | unsafe { self.as_ptr() == Py_False() } 199 | } 200 | } 201 | 202 | impl std::fmt::Display for PyObj { 203 | fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 204 | self.write_repr(f) 205 | } 206 | } 207 | -------------------------------------------------------------------------------- /tests/test_month_day.py: -------------------------------------------------------------------------------- 1 | import pickle 2 | import re 3 | from copy import copy, deepcopy 4 | 5 | import pytest 6 | 7 | from whenever import Date, MonthDay 8 | 9 | from .common import AlwaysEqual, AlwaysLarger, AlwaysSmaller, NeverEqual 10 | 11 | 12 | class TestInit: 13 | 14 | def test_valid(self): 15 | assert MonthDay(12, 3) is not None 16 | assert MonthDay(1, 1) is not None 17 | assert MonthDay(12, 31) is not None 18 | assert MonthDay(2, 29) is not None 19 | 20 | @pytest.mark.parametrize( 21 | "month, day", 22 | [ 23 | (13, 1), 24 | (2, 30), 25 | (8, 32), 26 | (0, 3), 27 | (10_000, 3), 28 | ], 29 | ) 30 | def test_invalid_combinations(self, month, day): 31 | with pytest.raises(ValueError): 32 | MonthDay(month, day) 33 | 34 | def test_invalid(self): 35 | with pytest.raises(TypeError): 36 | MonthDay(2) # type: ignore[call-overload] 37 | 38 | with pytest.raises(TypeError): 39 | MonthDay("20", "SEP") # type: ignore[call-overload] 40 | 41 | with pytest.raises(TypeError): 42 | MonthDay() # type: ignore[call-overload] 43 | 44 | def test_iso(self): 45 | assert MonthDay("--12-25") == MonthDay(12, 25) 46 | 47 | 48 | def test_properties(): 49 | md = MonthDay(12, 14) 50 | assert md.month == 12 51 | assert md.day == 14 52 | 53 | 54 | def test_is_leap(): 55 | assert MonthDay(2, 29).is_leap() 56 | assert not MonthDay(2, 28).is_leap() 57 | assert not MonthDay(3, 1).is_leap() 58 | assert not MonthDay(1, 1).is_leap() 59 | assert not MonthDay(12, 31).is_leap() 60 | 61 | 62 | def test_eq(): 63 | md = MonthDay(10, 12) 64 | same = MonthDay(10, 12) 65 | different = MonthDay(10, 11) 66 | 67 | assert md == same 68 | assert not md == different 69 | assert not md == NeverEqual() 70 | assert md == AlwaysEqual() 71 | 72 | assert not md != same 73 | assert md != different 74 | assert md != NeverEqual() 75 | assert not md != AlwaysEqual() 76 | assert md != None # noqa: E711 77 | assert None != md # noqa: E711 78 | assert not md == None # noqa: E711 79 | assert not None == md # noqa: E711 80 | 81 | assert hash(md) == hash(same) 82 | 83 | 84 | def test_comparison(): 85 | md = MonthDay(7, 5) 86 | same = MonthDay(7, 5) 87 | bigger = MonthDay(8, 2) 88 | smaller = MonthDay(6, 12) 89 | 90 | assert md <= same 91 | assert md <= bigger 92 | assert not md <= smaller 93 | assert md <= AlwaysLarger() 94 | assert not md <= AlwaysSmaller() 95 | 96 | assert not md < same 97 | assert md < bigger 98 | assert not md < smaller 99 | assert md < AlwaysLarger() 100 | assert not md < AlwaysSmaller() 101 | 102 | assert md >= same 103 | assert not md >= bigger 104 | assert md >= smaller 105 | assert not md >= AlwaysLarger() 106 | assert md >= AlwaysSmaller() 107 | 108 | assert not md > same 109 | assert not md > bigger 110 | assert md > smaller 111 | assert not md > AlwaysLarger() 112 | assert md > AlwaysSmaller() 113 | 114 | 115 | def test_format_iso(): 116 | assert MonthDay(11, 12).format_iso() == "--11-12" 117 | assert MonthDay(2, 1).format_iso() == "--02-01" 118 | 119 | 120 | def test_str(): 121 | assert str(MonthDay(10, 31)) == "--10-31" 122 | assert str(MonthDay(2, 1)) == "--02-01" 123 | 124 | 125 | def test_repr(): 126 | assert repr(MonthDay(11, 12)) == 'MonthDay("--11-12")' 127 | assert repr(MonthDay(2, 1)) == 'MonthDay("--02-01")' 128 | 129 | 130 | class TestParseIso: 131 | 132 | @pytest.mark.parametrize( 133 | "s, expected", 134 | [ 135 | ("--08-21", MonthDay(8, 21)), 136 | ("--10-02", MonthDay(10, 2)), 137 | # basic format 138 | ("--1002", MonthDay(10, 2)), 139 | ], 140 | ) 141 | def test_valid(self, s, expected): 142 | assert MonthDay.parse_iso(s) == expected 143 | 144 | @pytest.mark.parametrize( 145 | "s", 146 | [ 147 | "--2A-01", # non-digit 148 | "--11-01T03:04:05", # with a time 149 | "2021-01-02", # with a year 150 | "--11-1", # no padding 151 | "--1-13", # no padding 152 | "W12-04", # week date 153 | "03-12", # no dashes 154 | "-10-12", # not enough dashes 155 | "---12-03", # negative month 156 | "--1🧨-12", # non-ASCII 157 | "--1𝟙-11", # non-ascii 158 | # invalid components 159 | "--00-01", 160 | "--13-01", 161 | "--11-00", 162 | "--11-31", 163 | ], 164 | ) 165 | def test_invalid(self, s): 166 | with pytest.raises( 167 | ValueError, 168 | match=r"Invalid format.*" + re.escape(repr(s)), 169 | ): 170 | MonthDay.parse_iso(s) 171 | 172 | def test_no_string(self): 173 | with pytest.raises((TypeError, AttributeError), match="(int|str)"): 174 | MonthDay.parse_iso(20210102) # type: ignore[arg-type] 175 | 176 | 177 | def test_replace(): 178 | md = MonthDay(12, 31) 179 | assert md.replace(month=8) == MonthDay(8, 31) 180 | assert md.replace(day=8) == MonthDay(12, 8) 181 | assert md == MonthDay(12, 31) # original is unchanged 182 | 183 | with pytest.raises(ValueError, match="(day|month|date)"): 184 | md.replace(month=2) 185 | 186 | with pytest.raises(ValueError, match="(date|day)"): 187 | md.replace(day=32) 188 | 189 | with pytest.raises(TypeError): 190 | md.replace(3) # type: ignore[misc] 191 | 192 | with pytest.raises(TypeError, match="foo"): 193 | md.replace(foo=3) # type: ignore[call-arg] 194 | 195 | with pytest.raises(TypeError, match="year"): 196 | md.replace(year=2000) # type: ignore[call-arg] 197 | 198 | with pytest.raises(TypeError, match="foo"): 199 | md.replace(foo="blabla") # type: ignore[call-arg] 200 | 201 | with pytest.raises(ValueError, match="(date|month)"): 202 | md.replace(month=13) 203 | 204 | 205 | def test_in_year(): 206 | md = MonthDay(12, 28) 207 | assert md.in_year(2000) == Date(2000, 12, 28) 208 | assert md.in_year(4) == Date(4, 12, 28) 209 | 210 | with pytest.raises(ValueError): 211 | md.in_year(0) 212 | 213 | with pytest.raises(ValueError): 214 | md.in_year(10_000) 215 | 216 | with pytest.raises(ValueError): 217 | md.in_year(-1) 218 | 219 | leap_day = MonthDay(2, 29) 220 | assert leap_day.in_year(2000) == Date(2000, 2, 29) 221 | with pytest.raises(ValueError): 222 | leap_day.in_year(2001) 223 | 224 | 225 | def test_copy(): 226 | md = MonthDay(5, 1) 227 | assert copy(md) is md 228 | assert deepcopy(md) is md 229 | 230 | 231 | def test_singletons(): 232 | assert MonthDay.MIN == MonthDay(1, 1) 233 | assert MonthDay.MAX == MonthDay(12, 31) 234 | 235 | 236 | def test_pickling(): 237 | d = MonthDay(11, 1) 238 | dumped = pickle.dumps(d) 239 | assert pickle.loads(dumped) == d 240 | 241 | 242 | def test_unpickle_compatibility(): 243 | dumped = ( 244 | b"\x80\x04\x95#\x00\x00\x00\x00\x00\x00\x00\x8c\x08whenever\x94\x8c\t_unpkl_m" 245 | b"d\x94\x93\x94C\x02\x0b\x01\x94\x85\x94R\x94." 246 | ) 247 | assert pickle.loads(dumped) == MonthDay(11, 1) 248 | 249 | 250 | def test_cannot_subclass(): 251 | with pytest.raises(TypeError): 252 | 253 | class SubclassDate(MonthDay): # type: ignore[misc] 254 | pass 255 | -------------------------------------------------------------------------------- /scripts/generate_docstrings.py: -------------------------------------------------------------------------------- 1 | """This script ensures the Rust extension docstrings are identical to the 2 | Python ones. 3 | 4 | It does so by parsing the Python docstrings and generating a Rust file with the 5 | same docstrings. This file is then included in the Rust extension. 6 | """ 7 | 8 | import enum 9 | import inspect 10 | import sys 11 | from itertools import chain 12 | 13 | from whenever import _pywhenever as W 14 | 15 | assert sys.version_info >= ( 16 | 3, 17 | 13, 18 | ), "This script requires Python 3.13 or later due to how docstrings are rendered." 19 | 20 | classes = { 21 | cls 22 | for name, cls in W.__dict__.items() 23 | if ( 24 | not name.startswith("_") 25 | and inspect.isclass(cls) 26 | and cls.__module__ == "whenever" 27 | and not issubclass(cls, enum.Enum) 28 | ) 29 | } 30 | functions = { 31 | func 32 | for name, func in inspect.getmembers(W) 33 | if ( 34 | not name.startswith("_") 35 | and inspect.isfunction(func) 36 | and func.__module__ == "whenever" 37 | ) 38 | } 39 | 40 | 41 | methods = { 42 | getattr(cls, name) 43 | for cls in chain( 44 | classes, 45 | ( 46 | # some methods are documented in their ABCs 47 | W._BasicConversions, 48 | W._LocalTime, 49 | W._ExactTime, 50 | W._ExactAndLocalTime, 51 | ), 52 | ) 53 | for name, m in cls.__dict__.items() 54 | if ( 55 | not name.startswith("_") 56 | and ( 57 | inspect.isfunction(m) 58 | or 59 | # this catches classmethods 60 | inspect.ismethod(getattr(cls, name)) 61 | ) 62 | ) 63 | } 64 | 65 | MAGIC_STRINGS = { 66 | (name, value) 67 | for name, value in W.__dict__.items() 68 | if isinstance(value, str) and name.isupper() and not name.startswith("_") 69 | } 70 | 71 | CSTR_TEMPLATE = 'pub(crate) const {varname}: &CStr = c"\\\n{doc}";' 72 | STR_TEMPLATE = 'pub(crate) const {varname}: &str = "{value}";' 73 | HEADER = """\ 74 | // Do not manually edit this file. 75 | // It has been autogenerated by scripts/generate_docstrings.py 76 | use std::ffi::CStr; 77 | """ 78 | 79 | PYDANTIC_DOCSTRING = 'pub(crate) const PYDANTIC_SCHEMA: &CStr = c"__get_pydantic_core_schema__(source_type, handler)\\n--\\n\\n";' 80 | 81 | MANUALLY_DEFINED_SIGS: dict[object, str] = { 82 | W.ZonedDateTime.add: """\ 83 | ($self, delta=None, /, *, years=0, months=0, weeks=0, days=0, hours=0, \ 84 | minutes=0, seconds=0, milliseconds=0, microseconds=0, nanoseconds=0, \ 85 | disambiguate=None)""", 86 | W.ZonedDateTime.replace: """\ 87 | ($self, /, *, year=None, month=None, weeks=0, day=None, hour=None, \ 88 | minute=None, second=None, nanosecond=None, tz=None, disambiguate)""", 89 | W.OffsetDateTime.add: """\ 90 | ($self, delta=None, /, *, years=0, months=0, weeks=0, days=0, \ 91 | hours=0, minutes=0, seconds=0, milliseconds=0, microseconds=0, nanoseconds=0, \ 92 | ignore_dst=False)""", 93 | W.OffsetDateTime.replace: """\ 94 | ($self, /, *, year=None, month=None, weeks=0, day=None, hour=None, \ 95 | minute=None, second=None, nanosecond=None, offset=None, ignore_dst=False)""", 96 | W.PlainDateTime.add: """\ 97 | ($self, delta=None, /, *, years=0, months=0, weeks=0, days=0, \ 98 | hours=0, minutes=0, seconds=0, milliseconds=0, microseconds=0, nanoseconds=0, \ 99 | ignore_dst=False)""", 100 | W.PlainDateTime.replace: """\ 101 | ($self, /, *, year=None, month=None, day=None, hour=None, \ 102 | minute=None, second=None, nanosecond=None)""", 103 | W.Date.replace: "($self, /, *, year=None, month=None, day=None)", 104 | W.MonthDay.replace: "($self, /, *, month=None, day=None)", 105 | W.Time.replace: "($self, /, *, hour=None, minute=None, second=None, nanosecond=None)", 106 | W.YearMonth.replace: "($self, /, *, year=None, month=None)", 107 | W.Instant.add: """\ 108 | ($self, delta=None, /, *, hours=0, minutes=0, seconds=0, \ 109 | milliseconds=0, microseconds=0, nanoseconds=0)""", 110 | W.Date.add: "($self, delta=None, /, *, years=0, months=0, weeks=0, days=0)", 111 | } 112 | MANUALLY_DEFINED_SIGS.update( 113 | { 114 | W.ZonedDateTime.subtract: MANUALLY_DEFINED_SIGS[W.ZonedDateTime.add], 115 | W.OffsetDateTime.subtract: MANUALLY_DEFINED_SIGS[W.OffsetDateTime.add], 116 | W.PlainDateTime.subtract: MANUALLY_DEFINED_SIGS[W.PlainDateTime.add], 117 | W.Instant.subtract: MANUALLY_DEFINED_SIGS[W.Instant.add], 118 | W.Date.subtract: MANUALLY_DEFINED_SIGS[W.Date.add], 119 | } 120 | ) 121 | SKIP = { 122 | W._BasicConversions.format_iso, 123 | W._BasicConversions.from_py_datetime, 124 | W._BasicConversions.parse_iso, 125 | W._ExactTime.from_timestamp, 126 | W._ExactTime.from_timestamp_millis, 127 | W._ExactTime.from_timestamp_nanos, 128 | W._ExactTime.now, 129 | W._LocalTime.add, 130 | W._LocalTime.subtract, 131 | W._LocalTime.replace, 132 | W._LocalTime.replace_date, 133 | W._LocalTime.replace_time, 134 | W._LocalTime.round, 135 | } 136 | 137 | 138 | def method_doc(method): 139 | method.__annotations__.clear() 140 | try: 141 | sig = MANUALLY_DEFINED_SIGS[method] 142 | except KeyError: 143 | sig = ( 144 | str(inspect.signature(method)) 145 | # I escape the parens (\x28) because they mess up some LSPs 146 | # and text editors when viewing this script. 147 | .replace("\x28self", "\x28$self").replace("\x28cls", "\x28$type") 148 | ) 149 | doc = method.__doc__.replace('"', '\\"') 150 | sig_prefix = f"{method.__name__}{sig}\n--\n\n" 151 | return sig_prefix * _needs_text_signature(method) + doc 152 | 153 | 154 | # In some basic cases, such as 0 or 1-argument functions, Python 155 | # will automatically generate an adequate signature. 156 | def _needs_text_signature(method): 157 | sig = inspect.signature(method) 158 | params = list(sig.parameters.values()) 159 | if len(params) == 0: 160 | return False 161 | if params[0].name in {"self", "cls"}: 162 | params.pop(0) 163 | if len(params) > 1: 164 | return True 165 | elif len(params) == 0: 166 | return False 167 | else: 168 | return ( 169 | params[0].kind != inspect.Parameter.POSITIONAL_ONLY 170 | or params[0].default is not inspect.Parameter.empty 171 | ) 172 | 173 | 174 | def print_everything(): 175 | print(HEADER) 176 | print(PYDANTIC_DOCSTRING) 177 | for cls in sorted(classes, key=lambda x: x.__name__): 178 | assert cls.__doc__ 179 | print( 180 | CSTR_TEMPLATE.format( 181 | varname=cls.__name__.upper(), 182 | doc=cls.__doc__.replace('"', '\\"'), 183 | ) 184 | ) 185 | 186 | for func in sorted(functions, key=lambda x: x.__name__): 187 | assert func.__doc__ 188 | print( 189 | CSTR_TEMPLATE.format( 190 | varname=func.__name__.upper(), 191 | doc=func.__doc__.replace('"', '\\"'), 192 | ) 193 | ) 194 | 195 | for method in sorted(methods, key=lambda x: x.__qualname__): 196 | if method.__doc__ is None or method in SKIP: 197 | continue 198 | 199 | qualname = method.__qualname__ 200 | if qualname.startswith("_"): 201 | qualname = qualname[1:] 202 | print( 203 | CSTR_TEMPLATE.format( 204 | varname=qualname.replace(".", "_").upper(), 205 | doc=method_doc(method), 206 | ) 207 | ) 208 | 209 | for name, value in sorted(MAGIC_STRINGS): 210 | print(STR_TEMPLATE.format(varname=name, value=value)) 211 | 212 | 213 | if __name__ == "__main__": 214 | print_everything() 215 | -------------------------------------------------------------------------------- /src/common/rfc2822.rs: -------------------------------------------------------------------------------- 1 | //! Functionality for parsing/writing RFC 2822 formatted date strings. 2 | use crate::{ 3 | classes::{ 4 | date::{Date, extract_year}, 5 | instant::Instant, 6 | offset_datetime::OffsetDateTime, 7 | plain_datetime::DateTime, 8 | time::Time, 9 | }, 10 | common::{ 11 | fmt::*, 12 | parse::{Scan, extract_2_digits, extract_digit}, 13 | scalar::*, 14 | }, 15 | }; 16 | 17 | const TEMPLATE: [u8; 31] = *b"DDD, 00 MMM 0000 00:00:00 +0000"; 18 | const TEMPLATE_GMT: [u8; 29] = *b"DDD, 00 MMM 0000 00:00:00 GMT"; 19 | 20 | /// Format into a standard RFC 2822 date string. 21 | pub(crate) fn format(odt: OffsetDateTime) -> [u8; 31] { 22 | let OffsetDateTime { 23 | date, 24 | time: 25 | Time { 26 | hour, 27 | minute, 28 | second, 29 | .. 30 | }, 31 | offset, 32 | } = odt; 33 | let Date { year, month, day } = date; 34 | 35 | let mut buf = TEMPLATE; 36 | buf[..3].copy_from_slice(WEEKDAY_NAMES[date.day_of_week() as usize - 1]); 37 | buf[5..7].copy_from_slice(format_2_digits(day).as_ref()); 38 | buf[8..11].copy_from_slice(MONTH_NAMES[month as usize - 1]); 39 | buf[12..16].copy_from_slice(format_4_digits(year.get()).as_ref()); 40 | buf[17..19].copy_from_slice(format_2_digits(hour).as_ref()); 41 | buf[20..22].copy_from_slice(format_2_digits(minute).as_ref()); 42 | buf[23..25].copy_from_slice(format_2_digits(second).as_ref()); 43 | buf[26] = if offset.get() >= 0 { b'+' } else { b'-' }; 44 | let offset_abs = offset.get().abs(); 45 | buf[27..29].copy_from_slice(format_2_digits((offset_abs / 3600) as u8).as_ref()); 46 | buf[29..31].copy_from_slice(format_2_digits(((offset_abs % 3600) / 60) as u8).as_ref()); 47 | buf 48 | } 49 | 50 | /// Format into a standard RFC 2822 date string, using "GMT" as the timezone. 51 | pub(crate) fn format_gmt(i: Instant) -> [u8; 29] { 52 | let DateTime { 53 | date, 54 | time: 55 | Time { 56 | hour, 57 | minute, 58 | second, 59 | .. 60 | }, 61 | } = i.to_datetime(); 62 | let Date { year, month, day } = date; 63 | 64 | let mut buf = TEMPLATE_GMT; 65 | buf[..3].copy_from_slice(WEEKDAY_NAMES[date.day_of_week() as usize - 1]); 66 | buf[5..7].copy_from_slice(format_2_digits(day).as_ref()); 67 | buf[8..11].copy_from_slice(MONTH_NAMES[month as usize - 1]); 68 | buf[12..16].copy_from_slice(format_4_digits(year.get()).as_ref()); 69 | buf[17..19].copy_from_slice(format_2_digits(hour).as_ref()); 70 | buf[20..22].copy_from_slice(format_2_digits(minute).as_ref()); 71 | buf[23..25].copy_from_slice(format_2_digits(second).as_ref()); 72 | buf 73 | } 74 | 75 | const WEEKDAY_NAMES: [&[u8]; 7] = [b"Mon", b"Tue", b"Wed", b"Thu", b"Fri", b"Sat", b"Sun"]; 76 | const MONTH_NAMES: [&[u8]; 12] = [ 77 | b"Jan", b"Feb", b"Mar", b"Apr", b"May", b"Jun", b"Jul", b"Aug", b"Sep", b"Oct", b"Nov", b"Dec", 78 | ]; 79 | 80 | pub(crate) fn parse(s: &[u8]) -> Option<(Date, Time, Offset)> { 81 | let mut scan = Scan::new(s); 82 | scan.ascii_whitespace(); 83 | let expected_weekday = match scan.peek()? { 84 | c if c.is_ascii_alphabetic() => Some(parse_weekday(&mut scan)?), 85 | _ => None, 86 | }; 87 | let date = parse_date(&mut scan, expected_weekday)?; 88 | let time = parse_time(&mut scan)?; 89 | let offset = parse_offset(&mut scan)?; 90 | scan.ascii_whitespace(); 91 | scan.is_done().then_some((date, time, offset)) 92 | } 93 | 94 | fn parse_weekday(s: &mut Scan) -> Option { 95 | s.take(3) 96 | .and_then(|day_str| { 97 | WEEKDAY_NAMES.iter().enumerate().find_map(|(i, &b)| { 98 | day_str 99 | .eq_ignore_ascii_case(b) 100 | .then(|| Weekday::from_iso_unchecked(i as u8 + 1)) 101 | }) 102 | }) 103 | .and_then(|day| { 104 | s.ascii_whitespace(); 105 | s.expect(b',')?; 106 | s.ascii_whitespace(); 107 | Some(day) 108 | }) 109 | } 110 | 111 | fn parse_date(s: &mut Scan, expect_weekday: Option) -> Option { 112 | let day = s.up_to_2_digits()?; 113 | s.ascii_whitespace().then_some(())?; 114 | let month = s.take(3).and_then(|month_str| { 115 | MONTH_NAMES.iter().enumerate().find_map(|(i, &b)| { 116 | month_str 117 | .eq_ignore_ascii_case(b) 118 | .then(|| Month::new_unchecked(i as u8 + 1)) 119 | }) 120 | })?; 121 | s.ascii_whitespace().then_some(())?; 122 | let year = s 123 | .take_until(Scan::is_whitespace) 124 | .and_then(|y_str| match y_str.len() { 125 | 4 => extract_year(y_str, 0), 126 | 2 => extract_2_digits(y_str, 0).map(|y| { 127 | if y < 50 { 128 | Year::new_unchecked(2000 + y as u16) 129 | } else { 130 | Year::new_unchecked(1900 + y as u16) 131 | } 132 | }), 133 | 3 => Some(Year::new_unchecked( 134 | 1900 + (extract_digit(y_str, 0)? as u16) * 100 135 | + (extract_digit(y_str, 1)? as u16) * 10 136 | + (extract_digit(y_str, 2)? as u16), 137 | )), 138 | _ => None, 139 | })?; 140 | s.ascii_whitespace(); 141 | let date = Date::new(year, month, day)?; 142 | if let Some(weekday) = expect_weekday { 143 | if date.day_of_week() != weekday { 144 | return None; 145 | } 146 | } 147 | Some(date) 148 | } 149 | 150 | fn parse_time(s: &mut Scan) -> Option