diff --git a/.gitignore b/.gitignore index c8f04429..4a9c4df1 100644 --- a/.gitignore +++ b/.gitignore @@ -70,3 +70,5 @@ docs/_build/ # Pyenv .python-version + +.hypothesis diff --git a/.readthedocs.yml b/.readthedocs.yml new file mode 100644 index 00000000..e4f3bf4b --- /dev/null +++ b/.readthedocs.yml @@ -0,0 +1,17 @@ +version: 2 + +sphinx: + builder: html + configuration: docs/conf.py + fail_on_warning: true + +build: + os: ubuntu-22.04 + tools: + python: "3.11" + +python: + install: + - requirements: docs/requirements.txt + - method: pip + path: . diff --git a/CHANGELOG.rst b/CHANGELOG.rst new file mode 100644 index 00000000..e830f590 --- /dev/null +++ b/CHANGELOG.rst @@ -0,0 +1,17 @@ +Changelog +========= + +0.0.3 (2023-11-16) +------------------ + +- 🌐 Implement basic UTC-only ``DateTime`` + +0.0.2 (2023-11-10) +------------------ + +- ⚙️ Empty release with Rust extension module + +0.0.1 +----- + +- 📦 Dummy release diff --git a/Cargo.lock b/Cargo.lock index 438fc92c..728b45a6 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2,29 +2,127 @@ # It is not intended for manual editing. version = 3 +[[package]] +name = "android-tzdata" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e999941b234f3131b00bc13c22d06e8c5ff726d1b6318ac7eb276997bbb4fef0" + +[[package]] +name = "android_system_properties" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" +dependencies = [ + "libc", +] + [[package]] name = "autocfg" version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d468802bab17cbc0cc575e9b053f41e72aa36bfa6b7f55e3529ffa43161b97fa" +[[package]] +name = "bincode" +version = "1.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1f45e9417d87227c7a56d22e471c6206462cba514c7590c09aff4cf6d1ddcad" +dependencies = [ + "serde", +] + [[package]] name = "bitflags" version = "1.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" +[[package]] +name = "bumpalo" +version = "3.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f30e7476521f6f8af1a1c4c0b8cc94f0bee37d91763d0ca2665f299b6cd8aec" + +[[package]] +name = "cc" +version = "1.0.83" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1174fb0b6ec23863f8b971027804a42614e347eafb0a95bf0b12cdae21fc4d0" +dependencies = [ + "libc", +] + [[package]] name = "cfg-if" version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd" +[[package]] +name = "chrono" +version = "0.4.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f2c685bad3eb3d45a01354cedb7d5faa66194d1d58ba6e267a8de788f79db38" +dependencies = [ + "android-tzdata", + "iana-time-zone", + "js-sys", + "num-traits", + "serde", + "wasm-bindgen", + "windows-targets", +] + +[[package]] +name = "core-foundation-sys" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e496a50fda8aacccc86d7529e2c1e0892dbd0f898a6b5645b5561b89c3210efa" + +[[package]] +name = "heck" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95505c38b4572b2d910cecb0281560f54b440a19336cbbcb27bf6ce6adc6f5a8" + +[[package]] +name = "iana-time-zone" +version = "0.1.58" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8326b86b6cff230b97d0d312a6c40a60726df3332e721f72a1b035f451663b20" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "wasm-bindgen", + "windows-core", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + [[package]] name = "indoc" -version = "1.0.9" +version = "2.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e186cfbae8084e513daff4240b4797e342f988cecda4fb6c939150f96315fd8" + +[[package]] +name = "js-sys" +version = "0.3.65" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bfa799dd5ed20a7e349f3b4639aa80d74549c81716d9ec4f994c9b5815598306" +checksum = "54c0c35952f67de54bb584e9fd912b3023117cbafc0a77d8f3dee1fb5f572fe8" +dependencies = [ + "wasm-bindgen", +] [[package]] name = "libc" @@ -42,6 +140,12 @@ dependencies = [ "scopeguard", ] +[[package]] +name = "log" +version = "0.4.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5e6163cb8c49088c2c36f57875e58ccd8c87c7427f7fbd50ea6710b2f3f2e8f" + [[package]] name = "memoffset" version = "0.9.0" @@ -51,6 +155,15 @@ dependencies = [ "autocfg", ] +[[package]] +name = "num-traits" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39e3200413f237f41ab11ad6d161bc7239c84dcb631773ccd7de3dfe4b5c267c" +dependencies = [ + "autocfg", +] + [[package]] name = "once_cell" version = "1.18.0" @@ -91,9 +204,9 @@ dependencies = [ [[package]] name = "pyo3" -version = "0.19.2" +version = "0.20.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e681a6cfdc4adcc93b4d3cf993749a4552018ee0a9b65fc0ccfad74352c72a38" +checksum = "04e8453b658fe480c3e70c8ed4e3d3ec33eb74988bd186561b0cc66b85c3bc4b" dependencies = [ "cfg-if", "indoc", @@ -108,9 +221,9 @@ dependencies = [ [[package]] name = "pyo3-build-config" -version = "0.19.2" +version = "0.20.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "076c73d0bc438f7a4ef6fdd0c3bb4732149136abd952b110ac93e4edb13a6ba5" +checksum = "a96fe70b176a89cff78f2fa7b3c930081e163d5379b4dcdf993e3ae29ca662e5" dependencies = [ "once_cell", "target-lexicon", @@ -118,9 +231,9 @@ dependencies = [ [[package]] name = "pyo3-ffi" -version = "0.19.2" +version = "0.20.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e53cee42e77ebe256066ba8aa77eff722b3bb91f3419177cf4cd0f304d3284d9" +checksum = "214929900fd25e6604661ed9cf349727c8920d47deff196c4e28165a6ef2a96b" dependencies = [ "libc", "pyo3-build-config", @@ -128,9 +241,9 @@ dependencies = [ [[package]] name = "pyo3-macros" -version = "0.19.2" +version = "0.20.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dfeb4c99597e136528c6dd7d5e3de5434d1ceaf487436a3f03b2d56b6fc9efd1" +checksum = "dac53072f717aa1bfa4db832b39de8c875b7c7af4f4a6fe93cdbf9264cf8383b" dependencies = [ "proc-macro2", "pyo3-macros-backend", @@ -140,10 +253,11 @@ dependencies = [ [[package]] name = "pyo3-macros-backend" -version = "0.19.2" +version = "0.20.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "947dc12175c254889edc0c02e399476c2f652b4b9ebd123aa655c224de259536" +checksum = "7774b5a8282bd4f25f803b1f0d945120be959a36c72e08e7cd031c792fdfd424" dependencies = [ + "heck", "proc-macro2", "quote", "syn", @@ -173,6 +287,26 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" +[[package]] +name = "serde" +version = "1.0.192" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bca2a08484b285dcb282d0f67b26cadc0df8b19f8c12502c13d966bf9482f001" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.192" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6c7207fbec9faa48073f3e3074cbe553af6ea512d7c21ba46e434e70ea9fbc1" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "smallvec" version = "1.11.2" @@ -181,9 +315,9 @@ checksum = "4dccd0940a2dcdf68d092b8cbab7dc0ad8fa938bf95787e1b916b0e3d0e8e970" [[package]] name = "syn" -version = "1.0.109" +version = "2.0.39" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237" +checksum = "23e78b90f2fcf45d3e842032ce32e3f2d1545ba6636271dcbf24fa306d87be7a" dependencies = [ "proc-macro2", "quote", @@ -204,17 +338,82 @@ checksum = "3354b9ac3fae1ff6755cb6db53683adb661634f67557942dea4facebec0fee4b" [[package]] name = "unindent" -version = "0.1.11" +version = "0.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e1766d682d402817b5ac4490b3c3002d91dfa0d22812f341609f97b08757359c" +checksum = "c7de7d73e1754487cb58364ee906a499937a0dfabd86bcb980fa99ec8c8fa2ce" + +[[package]] +name = "wasm-bindgen" +version = "0.2.88" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7daec296f25a1bae309c0cd5c29c4b260e510e6d813c286b19eaadf409d40fce" +dependencies = [ + "cfg-if", + "wasm-bindgen-macro", +] + +[[package]] +name = "wasm-bindgen-backend" +version = "0.2.88" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e397f4664c0e4e428e8313a469aaa58310d302159845980fd23b0f22a847f217" +dependencies = [ + "bumpalo", + "log", + "once_cell", + "proc-macro2", + "quote", + "syn", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.88" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5961017b3b08ad5f3fe39f1e79877f8ee7c23c5e5fd5eb80de95abc41f1f16b2" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.88" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c5353b8dab669f5e10f5bd76df26a9360c748f054f862ff5f3f8aae0c7fb3907" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "wasm-bindgen-backend", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.88" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d046c5d029ba91a1ed14da14dca44b68bf2f124cfbaf741c54151fdb3e0750b" [[package]] name = "whenever" version = "0.0.2" dependencies = [ + "bincode", + "chrono", "pyo3", ] +[[package]] +name = "windows-core" +version = "0.51.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1f8cf84f35d2db49a46868f947758c7a1138116f7fac3bc844f43ade1292e64" +dependencies = [ + "windows-targets", +] + [[package]] name = "windows-targets" version = "0.48.5" diff --git a/Cargo.toml b/Cargo.toml index a90ad4ff..d5670ca5 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -8,4 +8,6 @@ name = "whenever" crate-type = ["cdylib"] [dependencies] -pyo3 = "0.19.0" +bincode = "1.3.3" +chrono = { version = "0.4.31", features = ["serde"] } +pyo3 = "0.20.0" diff --git a/Makefile b/Makefile new file mode 100644 index 00000000..9c1bec04 --- /dev/null +++ b/Makefile @@ -0,0 +1,23 @@ +.PHONY: build-dev +build-dev: + maturin develop --extras test + + +.PHONY: test +test: build-dev + pytest + +.PHONY: mypy +mypy: build-dev + mypy py/ tests/ + +.PHONY: format +format: + black py/ tests/ + isort py/ tests/ + cargo fmt + +.PHONY: docs +docs: build-dev + @touch docs/api.rst + make -C docs/ html diff --git a/README.md b/README.md deleted file mode 100644 index d2900e9f..00000000 --- a/README.md +++ /dev/null @@ -1,12 +0,0 @@ -# Whenever - -Experimental datetime library - -## Development - -Some useful commands: - -```python -maturin develop --extras test -pytest -``` diff --git a/README.rst b/README.rst new file mode 100644 index 00000000..db1467ef --- /dev/null +++ b/README.rst @@ -0,0 +1,131 @@ +⏰ Whenever +=========== + +.. image:: https://img.shields.io/pypi/v/whenever.svg?style=flat-square&color=blue + :target: https://pypi.python.org/pypi/whenever + +.. image:: https://img.shields.io/pypi/pyversions/whenever.svg?style=flat-square + :target: https://pypi.python.org/pypi/whenever + +.. image:: https://img.shields.io/pypi/l/whenever.svg?style=flat-square&color=blue + :target: https://pypi.python.org/pypi/whenever + +.. image:: https://img.shields.io/badge/mypy-strict-forestgreen?style=flat-square + :target: https://mypy.readthedocs.io/en/stable/command_line.html#cmdoption-mypy-strict + +.. image:: https://img.shields.io/github/actions/workflow/status/ariebovenberg/whenever/CI.yml?branch=main&style=flat-square + :target: https://github.com/ariebovenberg/whenever + +.. image:: https://img.shields.io/readthedocs/whenever.svg?style=flat-square + :target: http://whenever.readthedocs.io/ + +**Typesafe datetimes powered by Rust's chrono library.** + +*Currently a work in progress. Leave a ⭐️ if you're interested how this develops.* + +Why? +---- + +Most Python datetime libraries use a single class for +naive, timezoned, *and* offset datetimes, +making it all too easy to mistakenly (and *silently!*) mix them. +Your type checker and IDE are powerless to help you, +leaving you to discover these errors at runtime. + +**Whenever** takes a different approach, and provides dedicated datetime +types that reveal mistakes *before* you run your code. + +Types of datetime (and why they're important) +--------------------------------------------- + +🚧 **NOT YET FULLY IMPLEMENTED** 🚧 + +**Whenever** distinguishes these types: + +1. **Naive** datetime: A simple type that isn't aware of any timezone or + UTC offset. +2. **UTC**-only datetime: A fast and efficient type for when you + *only* want to deal with UTC. +3. **Offset** datetime: A datetime with a *fixed* offset from UTC. +4. **Zoned** datetime: A datetime within a timezone, often with a variable + UTC offset. Zoned datetimes may be ambiguous or non-existent. + +Below is a table of supported operations for each type: + ++-----------------------+-------+-----+--------+-------+ +| Operation | Naive | UTC | Offset | Zoned | ++=======================+=======+=====+========+=======+ +| comparison | ✅ | ✅ | ✅ | ❌ | ++-----------------------+-------+-----+--------+-------+ +| difference | ✅ | ✅ | ✅ | ❌ | ++-----------------------+-------+-----+--------+-------+ +| add/subtract duration | ✅ | ✅ | ✅ | ❌ | ++-----------------------+-------+-----+--------+-------+ +| to timestamp | ❌ | ✅ | ✅ | ⚠️ | ++-----------------------+-------+-----+--------+-------+ +| from timestamp | ❌ | ✅ | ✅ | ✅ | ++-----------------------+-------+-----+--------+-------+ +| now() | ❌ | ✅ | ✅ | ✅ | ++-----------------------+-------+-----+--------+-------+ +| to naive | n/a | ✅ | ✅ | ✅ | ++-----------------------+-------+-----+--------+-------+ +| to UTC | ❌ | n/a | ✅ | ⚠️ | ++-----------------------+-------+-----+--------+-------+ +| to offset | ❌ | ✅ | n/a | ⚠️ | ++-----------------------+-------+-----+--------+-------+ +| to zoned | ❌ | ✅ | ✅ | n/a | ++-----------------------+-------+-----+--------+-------+ + +⚠️ = returns 0, 1, or 2 results, which must explicitly be handled. + +❌ = Too ambiguous to provide a sensible result. + +Quickstart +---------- + +.. code-block:: python + + from whenever.utc import DateTime + + # Explicit types for functional/Rust-style error handling + d = DateTime.new(2020, 1, 1, 12, 0, 0).unwrap() + + match DateTime.parse("2020-08-15T12:08:30Z"): + case Some(d2) if d < d2: + print('parsed a datetime after 2020-01-01T12:00:00Z') + case None: + print('failed to parse') + + d.timestamp() # UNIX timestamp + d.to_py() # convert to Python's datetime.datetime + + +Versioning and compatibility policy +----------------------------------- + +**Whenever** follows semantic versioning. +Until the 1.0 version, the API may change with minor releases. +Breaking changes will be announced in the changelog. +Since the API is fully typed, your typechecker and/or IDE +will help you adjust to any API changes. + +Acknowledgements +---------------- + +This project is inspired by the following projects. Check them out! + +- `DateType `_ +- `Pendulum `_ +- `Noda Time `_ +- `Chrono `_ + +Development +----------- + +An example of setting up things and running the tests: + +.. code-block:: bash + + pip install maturin + maturin develop --extras test + pytest diff --git a/docs/Makefile b/docs/Makefile new file mode 100644 index 00000000..66e77bc9 --- /dev/null +++ b/docs/Makefile @@ -0,0 +1,20 @@ +# Minimal makefile for Sphinx documentation +# + +# You can set these variables from the command line. +SPHINXOPTS = +SPHINXBUILD = sphinx-build +SPHINXPROJ = Quiz +SOURCEDIR = . +BUILDDIR = _build + +# Put it first so that "make" without argument is like "make help". +help: + @$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) + +.PHONY: help Makefile + +# Catch-all target: route all unknown targets to Sphinx using the new +# "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS). +%: Makefile + @$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) \ No newline at end of file diff --git a/docs/api.rst b/docs/api.rst new file mode 100644 index 00000000..f84ec59b --- /dev/null +++ b/docs/api.rst @@ -0,0 +1,20 @@ +.. _api: + +API reference +============= + +Unless otherwise noted, all classes are immutable. + + +whenever.utc +------------ + +.. automodule:: whenever.utc + :members: DateTime + + +whenever +-------- + +.. automodule:: whenever._common + :members: diff --git a/docs/changelog.rst b/docs/changelog.rst new file mode 100644 index 00000000..565b0521 --- /dev/null +++ b/docs/changelog.rst @@ -0,0 +1 @@ +.. include:: ../CHANGELOG.rst diff --git a/docs/conf.py b/docs/conf.py new file mode 100644 index 00000000..b19b9873 --- /dev/null +++ b/docs/conf.py @@ -0,0 +1,51 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +# +# documentation build configuration file, created by +# sphinx-quickstart on Tue Jun 13 22:58:12 2017. +# +# This file is execfile()d with the current directory set to its +# containing dir. +# +# Note that not all possible configuration values are present in this +# autogenerated file. +# +# All configuration values have a default; values that are commented out +# serve to show the default. + +from __future__ import annotations + +# -- Project information ----------------------------------------------------- +import importlib.metadata + +metadata = importlib.metadata.metadata("whenever") + +project = metadata["Name"] +version = metadata["Version"] +release = metadata["Version"] + + +# -- General configuration ------------------------------------------------ + +extensions = [ + "sphinx.ext.autodoc", + "sphinx.ext.intersphinx", + "sphinx.ext.napoleon", + "sphinx.ext.viewcode", + "sphinx_autodoc_typehints", +] +templates_path = ["_templates"] +source_suffix = ".rst" + +master_doc = "index" +exclude_patterns = ["_build", "Thumbs.db", ".DS_Store"] + +# -- Options for HTML output ---------------------------------------------- + +autodoc_member_order = "bysource" +html_theme = "furo" +highlight_language = "python3" +pygments_style = "default" +intersphinx_mapping = { + "python": ("https://docs.python.org/3", None), +} diff --git a/docs/index.rst b/docs/index.rst new file mode 100644 index 00000000..93482090 --- /dev/null +++ b/docs/index.rst @@ -0,0 +1,16 @@ +.. include:: ../README.rst + +Contents +======== + +.. toctree:: + :maxdepth: 2 + + api.rst + changelog.rst + +Indices and tables +================== + +* :ref:`genindex` +* :ref:`modindex` diff --git a/docs/requirements.txt b/docs/requirements.txt new file mode 100644 index 00000000..ae36f314 --- /dev/null +++ b/docs/requirements.txt @@ -0,0 +1,3 @@ +sphinx<7.3 +furo~=2023.9.10 +sphinx-autodoc-typehints~=1.25 diff --git a/py/whenever/__init__.py b/py/whenever/__init__.py index a6633838..c9db9f1c 100644 --- a/py/whenever/__init__.py +++ b/py/whenever/__init__.py @@ -1,5 +1,18 @@ -from . import _whenever +from abc import ABC +from typing import Generic, TypeVar +from ._whenever import _common, utc -class DateTime: +__all__ = ["utc", "Option", "Some", "Nothing"] + +T = TypeVar("T") + + +class Option(Generic[T], ABC): pass + + +Some = _common.Some +Nothing = _common.Nothing +Option.register(Some) +Option.register(Nothing) diff --git a/py/whenever/__init__.pyi b/py/whenever/__init__.pyi new file mode 100644 index 00000000..07cce3d4 --- /dev/null +++ b/py/whenever/__init__.pyi @@ -0,0 +1,4 @@ +from . import utc +from ._common import Nothing, Option, Some + +__all__ = ["utc", "Option", "Some", "Nothing"] diff --git a/py/whenever/_common.pyi b/py/whenever/_common.pyi new file mode 100644 index 00000000..7a18b3e8 --- /dev/null +++ b/py/whenever/_common.pyi @@ -0,0 +1,14 @@ +from typing import Generic, TypeVar + +T = TypeVar("T") + +class Option(Generic[T]): + def unwrap(self) -> T: ... + +class Some(Option[T]): + __match_args__ = ("value",) + value: T + def __init__(self, value: T, /): ... + +class Nothing(Option[T]): + pass diff --git a/py/whenever/py.typed b/py/whenever/py.typed new file mode 100644 index 00000000..e69de29b diff --git a/py/whenever/utc.pyi b/py/whenever/utc.pyi new file mode 100644 index 00000000..ceb30b13 --- /dev/null +++ b/py/whenever/utc.pyi @@ -0,0 +1,48 @@ +from datetime import datetime +from typing import Never + +from ._common import Option + +class DateTime: + def __init__( + self, _use_new_method_instead_of_constructor_: Never + ) -> None: ... + @staticmethod + def new( + year: int, + month: int, + day: int, + hour: int = 0, + min: int = 0, + sec: int = 0, + nano: int = 0, + /, + ) -> Option[DateTime]: ... + @staticmethod + def parse(s: str, /) -> Option[DateTime]: ... + def timestamp(self) -> float: ... + def timestamp_millis(self) -> float: ... + @staticmethod + def from_timestamp(i: int, /) -> Option[DateTime]: ... + @staticmethod + def from_timestamp_millis(i: int, /) -> Option[DateTime]: ... + @property + def year(self) -> int: ... + @property + def month(self) -> int: ... + @property + def day(self) -> int: ... + @property + def hour(self) -> int: ... + @property + def minute(self) -> int: ... + @property + def second(self) -> int: ... + @property + def nanosecond(self) -> int: ... + def __lt__(self, other: DateTime) -> bool: ... + def __le__(self, other: DateTime) -> bool: ... + def __gt__(self, other: DateTime) -> bool: ... + def __ge__(self, other: DateTime) -> bool: ... + def __hash__(self) -> int: ... + def to_py(self) -> datetime: ... diff --git a/pyproject.toml b/pyproject.toml index 7f56cbcb..5264072f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,12 +1,20 @@ [project] name = "whenever" -description = "Experimental datetime library" +description = "Typesafe datetimes powered by Rust's chrono library" requires-python = ">=3.8" +authors = [ + {name = "Arie Bovenberg", email = "a.c.bovenberg@gmail.com>"} +] license = "MIT" classifiers = [ "Programming Language :: Rust", "Programming Language :: Python :: Implementation :: CPython", "Programming Language :: Python :: Implementation :: PyPy", + "Programming Language :: Python :: 3.8", + "Programming Language :: Python :: 3.9", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", ] dynamic = ["version"] @@ -15,6 +23,9 @@ test = [ "pytest~=7.4", "black~=23.3", "isort~=5.12", + "pytest-mypy-plugins~=3.0", + "hypothesis~=6.0", + "pytest-benchmark~=4.0", ] @@ -44,6 +55,17 @@ exclude = ''' profile = 'black' line_length = 79 +[tool.mypy] +warn_unused_ignores = true +strict = true + +[[tool.mypy.overrides]] +module = [ + "tests.*", +] +check_untyped_defs = true +disable_error_code = ["no-untyped-def"] + [build-system] requires = ["maturin>=1.3,<2.0"] build-backend = "maturin" diff --git a/pytest.ini b/pytest.ini new file mode 100644 index 00000000..dec59131 --- /dev/null +++ b/pytest.ini @@ -0,0 +1,4 @@ +[pytest] +addopts= + --benchmark-disable + --mypy-ini-file=tests/mypy.ini diff --git a/src/common.rs b/src/common.rs new file mode 100644 index 00000000..80b763de --- /dev/null +++ b/src/common.rs @@ -0,0 +1,89 @@ +use pyo3::types::{PyAny, PyTuple, PyType}; +use pyo3::{intern, prelude::*}; + +#[pyclass(frozen, name = "Some", module = "whenever")] +pub struct PySome { + #[pyo3(get)] + pub value: PyObject, +} + +#[pymethods] +impl PySome { + #[new] + fn new(value: PyObject) -> Self { + PySome { value } + } + + fn unwrap(&self, py: Python) -> PyResult { + Ok(self.value.clone_ref(py)) + } + + pub fn __repr__(&self, py: Python) -> PyResult { + Ok(format!("Some({})", self.value.as_ref(py).repr()?,)) + } + + fn __eq__(&self, py: Python, rhs: &PySome) -> PyResult { + self.value.as_ref(py).eq(&rhs.value) + } + + fn __hash__(&self, py: Python) -> PyResult { + self.value.as_ref(py).hash() + } + + #[classmethod] + pub fn __class_getitem__(cls: &PyType, py: Python, _item: &PyAny) -> Py { + cls.into_py(py) + } + + #[classattr] + fn __match_args__(py: Python) -> &PyTuple { + PyTuple::new(py, vec![intern!(py, "value")]) + } +} + +#[pyclass(frozen, name = "Nothing", module = "whenever")] +pub struct PyNothing; + +#[pymethods] +impl PyNothing { + #[new] + fn new() -> Self { + // TODO: singleton? + PyNothing {} + } + + fn unwrap(&self) -> PyResult { + Err(pyo3::exceptions::PyValueError::new_err( + "called `unwrap` on a `Nothing` value", + )) + } + + fn __eq__(&self, _rhs: &PyNothing) -> bool { + true + } + + fn __hash__(&self) -> u64 { + 0 + } + + fn __bool__(&self) -> bool { + false + } + + pub fn __repr__(&self) -> &'static str { + "whenever.Nothing()" + } + + #[classmethod] + pub fn __class_getitem__(cls: &PyType, py: Python, _item: &PyAny) -> Py { + cls.into_py(py) + } +} + +pub fn submodule(py: Python) -> PyResult<&PyModule> { + let m = PyModule::new(py, "_common")?; + m.add("_NOTHING", (PyNothing {}).into_py(py))?; + m.add_class::()?; + m.add_class::()?; + Ok(m) +} diff --git a/src/lib.rs b/src/lib.rs index 602cbb4e..124eef82 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,7 +1,21 @@ use pyo3::prelude::*; +mod common; +mod utc; + #[pymodule] -#[pyo3(name = "_whenever")] -fn whenever(_py: Python, _m: &PyModule) -> PyResult<()> { +fn _whenever(py: Python, m: &PyModule) -> PyResult<()> { + let mod_utc = utc::submodule(py)?; + let mod_common = common::submodule(py)?; + + m.add_submodule(mod_utc)?; + m.add_submodule(mod_common)?; + + // See github.com/PyO3/pyo3/issues/759 + let sys_modules = py.import("sys")?.getattr("modules")?; + sys_modules.set_item("whenever.utc", mod_utc)?; + sys_modules.set_item("whenever._common", mod_common)?; + mod_utc.setattr("__name__", "whenever.utc")?; + mod_common.setattr("__name__", "whenever._common")?; Ok(()) } diff --git a/src/utc.rs b/src/utc.rs new file mode 100644 index 00000000..2c5b7d05 --- /dev/null +++ b/src/utc.rs @@ -0,0 +1,215 @@ +use chrono::{self, NaiveDateTime}; +use chrono::{Datelike, Timelike}; +use pyo3::prelude::*; +use pyo3::sync::GILOnceCell; +use pyo3::types::PyDateTime; +use std::collections::hash_map::DefaultHasher; +use std::hash::{Hash, Hasher}; + +use crate::common::{PyNothing, PySome}; + +/// Efficient UTC-only datetime +#[pyclass(frozen, module = "whenever.utc", weakref)] +struct DateTime { + inner: chrono::NaiveDateTime, +} + +#[pymethods] +impl DateTime { + #[new] + fn py_new() -> PyResult { + Err(pyo3::exceptions::PyTypeError::new_err( + "Cannot create a DateTime directly, use static methods like `new` instead", + )) + } + + /// Construct a DateTime from components + #[staticmethod] + #[pyo3(signature = (year, month, day, hour=0, min=0, sec=0, nano=0))] + fn new( + py: Python, + year: i32, + month: u32, + day: u32, + hour: u32, + min: u32, + sec: u32, + nano: u32, + ) -> PyObject { + match chrono::NaiveDate::from_ymd_opt(year, month, day) + .and_then(|d| d.and_hms_nano_opt(hour, min, sec, nano)) + { + Some(inner) => PySome { + value: DateTime { inner }.into_py(py), + } + .into_py(py), + None => (PyNothing {}).into_py(py), + } + } + + /// Parse a string in the format of ``YYYY-MM-DDTHH:MM:SS[.f]Z`` + #[staticmethod] + fn parse(py: Python, s: &str) -> PyObject { + match s.chars().last() { + Some('Z') => match s[..s.len() - 1].parse::() { + Ok(inner) => PySome { + value: DateTime { inner }.into_py(py), + } + .into_py(py), + _ => (PyNothing {}).into_py(py), + }, + _ => (PyNothing {}).into_py(py), + } + } + + /// Get the UNIX timestamp of this DateTime + fn timestamp(&self) -> i64 { + self.inner.timestamp() + } + + /// Construct a datetime from a UNIX timestamp + #[staticmethod] + fn from_timestamp(py: Python, timestamp: i64) -> PyObject { + match chrono::NaiveDateTime::from_timestamp_opt(timestamp, 0) { + Some(inner) => PySome { + value: DateTime { inner }.into_py(py), + } + .into_py(py), + None => (PyNothing {}).into_py(py), + } + } + + /// Get the UNIX timestamp of this DateTime in milliseconds + fn timestamp_millis(&self) -> i64 { + self.inner.timestamp_millis() + } + + /// Construct a datetime from a UNIX timestamp in milliseconds + #[staticmethod] + fn from_timestamp_millis(py: Python, timestamp: i64) -> PyObject { + match chrono::NaiveDateTime::from_timestamp_opt( + timestamp / 1000, + ((timestamp % 1000) * 1_000_000) as u32, + ) { + Some(inner) => PySome { + value: DateTime { inner }.into_py(py), + } + .into_py(py), + None => (PyNothing {}).into_py(py), + } + } + + /// Convert this datetime to a Python :class:`datetime.datetime` object + fn to_py<'a>(&self, py: Python<'a>) -> PyResult<&'a PyDateTime> { + PyDateTime::new( + py, + self.inner.year(), + self.inner.month() as u8, + self.inner.day() as u8, + self.inner.hour() as u8, + self.inner.minute() as u8, + self.inner.second() as u8, + self.inner.nanosecond() / 1_000, + None, + ) + } + + #[getter] + fn year(&self) -> i32 { + self.inner.year() + } + + #[getter] + fn month(&self) -> u32 { + self.inner.month() + } + + #[getter] + fn day(&self) -> u32 { + self.inner.day() + } + + #[getter] + fn hour(&self) -> u32 { + self.inner.hour() + } + + #[getter] + fn minute(&self) -> u32 { + self.inner.minute() + } + + #[getter] + fn second(&self) -> u32 { + self.inner.second() + } + + #[getter] + fn nanosecond(&self) -> u32 { + self.inner.nanosecond() + } + + fn __eq__(&self, rhs: &DateTime) -> bool { + self.inner == rhs.inner + } + + fn __lt__(&self, rhs: &DateTime) -> bool { + self.inner < rhs.inner + } + + fn __le__(&self, rhs: &DateTime) -> bool { + self.inner <= rhs.inner + } + + fn __gt__(&self, rhs: &DateTime) -> bool { + self.inner > rhs.inner + } + + fn __ge__(&self, rhs: &DateTime) -> bool { + self.inner >= rhs.inner + } + + fn __hash__(&self) -> u64 { + let mut hasher = DefaultHasher::new(); + self.inner.hash(&mut hasher); + hasher.finish() + } + + fn __repr__(&self) -> String { + format!("whenever.utc.DateTime({:?}Z)", self.inner) + } + + fn __reduce__(&self, py: Python) -> PyResult<(PyObject, PyObject)> { + Ok(( + DATETIME_UNPICKLER.get(py).unwrap().clone(), + ( + self.inner.timestamp().to_object(py), + self.inner.timestamp_subsec_nanos().to_object(py), + ) + .to_object(py), + )) + } +} + +// Because the constructor cannot be called from Python, we have a custom unpickler. +// We give it a short name in Python because it contributes to the pickled size +#[pyfunction(name = "_ud")] +fn unpickle_datetime(secs: i64, nsecs: u32) -> DateTime { + DateTime { + inner: NaiveDateTime::from_timestamp_opt(secs, nsecs).unwrap(), + } +} + +static DATETIME_UNPICKLER: GILOnceCell = GILOnceCell::new(); + +pub fn submodule(py: Python<'_>) -> PyResult<&PyModule> { + let m = PyModule::new(py, "utc")?; + m.add_class::()?; + + let unpickle_func = wrap_pyfunction!(unpickle_datetime, m)?; + DATETIME_UNPICKLER.set(py, unpickle_func.into()).unwrap(); + unpickle_func.setattr("__module__", "whenever.utc")?; + m.add_function(unpickle_func)?; + + Ok(m) +} diff --git a/tests/mypy.ini b/tests/mypy.ini new file mode 100644 index 00000000..903eab19 --- /dev/null +++ b/tests/mypy.ini @@ -0,0 +1,7 @@ +[mypy] +strict = true + +# Somehow an error is triggered in here when running pytest-mypy-plugin +# Thus, we ignore errors any errors there. +[mypy-builtins.*] +ignore_errors = true diff --git a/tests/test_common.py b/tests/test_common.py new file mode 100644 index 00000000..0cd86852 --- /dev/null +++ b/tests/test_common.py @@ -0,0 +1,102 @@ +import pytest + +from whenever import Nothing, Option, Some + + +class TestSome: + def test_unwrap(self): + assert Some(42).unwrap() == 42 + assert Some(42).value == 42 + + def test_equality(self): + x: Option[int] = Some(42) + same: Option[int] = Some(42) + different: Option[int] = Some(43) + nothing: Option[int] = Nothing() + + assert x == same + assert not x == different + assert not x == nothing + assert not x == 42 # type: ignore[comparison-overlap] + + assert x != different + assert not x != same + assert x != 42 # type: ignore[comparison-overlap] + assert x != nothing + + assert hash(x) == hash(same) + assert hash(x) != hash(different) + + def test_match(self): + s = Some(42) + match s: + case Some(v): + assert v == 42 + case Nothing(): + assert False + case _: + assert False + + def test_generic(self): + s = Some[int](42) + assert s == Some(42) + + def test_inheritance(self): + s = Some(42) + assert isinstance(s, Some) + assert isinstance(s, Option) + assert not isinstance(s, Nothing) + + def test_bool(self): + assert bool(Some(42)) + assert bool(Some(0)) + + +class TestNothing: + def test_unwrap(self): + with pytest.raises(ValueError): + Nothing().unwrap() + + def test_equality(self): + x: Option[int] = Nothing() + same: Option[int] = Nothing() + + assert x == same + assert not x == 42 # type: ignore[comparison-overlap] + + assert x != 42 # type: ignore[comparison-overlap] + assert not x != same + + assert hash(x) == hash(same) + assert hash(x) != hash(42) + + def test_inheritance(self): + n = Nothing[int]() + assert isinstance(n, Nothing) + assert isinstance(n, Option) + assert not isinstance(n, Some) + + def test_repr(self): + assert repr(Nothing()) == "whenever.Nothing()" + + def test_bool(self): + assert not bool(Nothing()) + + def test_match(self): + n = Nothing[str]() + match n: + case Some(_): + assert False + case Nothing(): + assert True + case _: + assert False + + def test_generic(self): + n = Nothing[int]() + assert n == Nothing() + + def test_no_other_attributes(self): + n = Nothing[int]() + with pytest.raises(AttributeError): + n.foo = 4 # type: ignore[attr-defined] diff --git a/tests/test_main.py b/tests/test_main.py deleted file mode 100644 index ec9e3676..00000000 --- a/tests/test_main.py +++ /dev/null @@ -1,2 +0,0 @@ -def test_imports_fine(): - from whenever import DateTime diff --git a/tests/utc/__init__.py b/tests/utc/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/utc/test_datetime.py b/tests/utc/test_datetime.py new file mode 100644 index 00000000..00f04a95 --- /dev/null +++ b/tests/utc/test_datetime.py @@ -0,0 +1,180 @@ +import pickle +from datetime import datetime as py_datetime + +import pytest +from hypothesis import given +from hypothesis.strategies import text + +from whenever import Nothing +from whenever.utc import DateTime + + +def test_imports(): + from whenever import utc + + assert utc.__name__ == "whenever.utc" + + +def test_cannot_construct(): + with pytest.raises(TypeError, match="use static methods.*instead"): + DateTime() # type: ignore[call-arg] + + +def test_minimal(): + d = DateTime.new(2020, 8, 15, 5, 12, 30, 450).unwrap() + assert d.year == 2020 + assert d.month == 8 + assert d.day == 15 + assert d.hour == 5 + assert d.minute == 12 + assert d.second == 30 + assert d.nanosecond == 450 + + assert ( + DateTime.new(2020, 8, 15, 12) + == DateTime.new(2020, 8, 15, 12, 0) + == DateTime.new(2020, 8, 15, 12, 0, 0) + == DateTime.new(2020, 8, 15, 12, 0, 0, 0) + ) + + +def test_new_invalid(): + assert DateTime.new(2020, 2, 30) == Nothing() + + +def test_immutable(): + d = DateTime.new(2020, 8, 15).unwrap() + with pytest.raises(AttributeError): + d.year = 2021 # type: ignore[misc] + + +class TestParse: + def test_valid(self): + assert DateTime.parse("2020-08-15T12:08:30Z") == DateTime.new( + 2020, 8, 15, 12, 8, 30 + ) + + def test_valid_unpadded(self): + assert DateTime.parse("2020-8-15T12:8:30Z") == DateTime.new( + 2020, 8, 15, 12, 8, 30 + ) + + def test_valid_fraction(self): + assert DateTime.parse("2020-08-15T12:08:30.346Z") == DateTime.new( + 2020, 8, 15, 12, 8, 30, 346_000_000 + ) + + def test_overly_precise_fraction(self): + assert DateTime.parse( + "2020-08-15T12:08:30.123456789123Z" + ) == DateTime.new(2020, 8, 15, 12, 8, 30, 123_456_789) + + def test_invalid_lowercase_z(self): + assert not DateTime.parse("2020-08-15T12:08:30z") + + def test_no_trailing_z(self): + assert not DateTime.parse("2020-08-15T12:08:30") + + def test_no_seconds(self): + assert not DateTime.parse("2020-08-15T12:08Z") + + def test_empty(self): + assert not DateTime.parse("") + + def test_garbage(self): + assert not DateTime.parse("garbage") + + @given(text()) + def test_fuzzing(self, s: str): + DateTime.parse(s) # should not raise an exception, ever + + +def test_equality(): + d = DateTime.new(2020, 8, 15).unwrap() + different = DateTime.new(2020, 8, 16).unwrap() + same = DateTime.new(2020, 8, 15).unwrap() + assert d == same + assert d != different + assert not d == different + assert not d != same + + assert hash(d) == hash(same) + assert hash(d) != hash(different) + + assert d != 42 # type: ignore[comparison-overlap] + assert not d == 42 # type: ignore[comparison-overlap] + + assert ( + DateTime.new(2020, 8, 15, 12, 8, 30).unwrap() + != DateTime.new(2020, 8, 15, 12, 8, 31).unwrap() + ) + + +def test_timestamp(): + assert DateTime.new(1970, 1, 1).unwrap().timestamp() == 0 + assert ( + DateTime.new(2020, 8, 15, 12, 8, 30).unwrap().timestamp() + == 1_597_493_310 + ) + + +def test_from_timestamp(): + assert ( + DateTime.from_timestamp(0).unwrap() + == DateTime.new(1970, 1, 1).unwrap() + ) + assert ( + DateTime.from_timestamp(1_597_493_310).unwrap() + == DateTime.new(2020, 8, 15, 12, 8, 30).unwrap() + ) + # TODO: test out-of-bounds of i64 and datetime + + +def test_timestamp_millis(): + assert DateTime.new(1970, 1, 1).unwrap().timestamp_millis() == 0 + assert ( + DateTime.new(2020, 8, 15, 12, 8, 30, 123_456_789) + .unwrap() + .timestamp_millis() + == 1_597_493_310_123 + ) + # TODO: test out-of-bounds of i64 and datetime + + +def test_from_timestamp_millis(): + assert ( + DateTime.from_timestamp_millis(0).unwrap() + == DateTime.new(1970, 1, 1).unwrap() + ) + assert ( + DateTime.from_timestamp_millis(1_597_493_310_123).unwrap() + == DateTime.new(2020, 8, 15, 12, 8, 30, 123_000_000).unwrap() + ) + + +def test_repr(): + d = DateTime.new(2020, 8, 15, 23, 12, 9, 987_654_000).unwrap() + assert repr(d) == "whenever.utc.DateTime(2020-08-15T23:12:09.987654Z)" + + +def test_pickle(benchmark): + d = DateTime.new(2020, 8, 15, 23, 12, 9, 987_654_000).unwrap() + dumped = benchmark(pickle.dumps, d) + assert pickle.loads(dumped) == d + + +def test_comparison(): + d = DateTime.parse("2020-08-15T23:12:09Z").unwrap() + later = DateTime.parse("2020-08-16T00:00:00Z").unwrap() + assert d < later + assert d <= later + assert later > d + assert later >= d + + with pytest.raises(TypeError): + d < 42 # type: ignore[operator] + + +def test_to_py(): + d = DateTime.new(2020, 8, 15, 23, 12, 9, 987_654_000).unwrap() + assert d.to_py() == py_datetime(2020, 8, 15, 23, 12, 9, 987_654) diff --git a/typesafety/test_common.yml b/typesafety/test_common.yml new file mode 100644 index 00000000..f763d79d --- /dev/null +++ b/typesafety/test_common.yml @@ -0,0 +1,16 @@ +- case: strict_equality + regex: true + main: | + from whenever import Some, Nothing, Option + a: Some[int] + a_a: Some[int] + b: Some[str] + c: Nothing[bool] + d: Option[int] + e: Option[str] + + a == 4 # E: .*comparison.* + a == b # E: .*comparison.* + a == a_a + a == c # E: .*comparison.* + d == e # E: .*comparison.* diff --git a/typesafety/utc/test_datetime.yml b/typesafety/utc/test_datetime.yml new file mode 100644 index 00000000..78349f2f --- /dev/null +++ b/typesafety/utc/test_datetime.yml @@ -0,0 +1,17 @@ +- case: ymd_arguments + regex: true + main: | + from whenever.utc import DateTime + d = DateTime.new(2020, 8, 9) + d = DateTime.new(2020, 8, '15') # E: .*incompatible type "str".* "int" +- case: no_constructor + regex: true + main: | + from whenever.utc import DateTime + d = DateTime(2020) # E: .*NoReturn +- case: strict_equality + regex: true + main: | + from whenever.utc import DateTime + d = DateTime.new(2020, 8, 9).unwrap() + d == 3 # E: .*comparison.*