Skip to content

Commit

Permalink
ensure docstrings consistent between Rust/Python
Browse files Browse the repository at this point in the history
  • Loading branch information
ariebovenberg committed Nov 27, 2024
1 parent ab1b124 commit 96e4da1
Show file tree
Hide file tree
Showing 26 changed files with 730 additions and 634 deletions.
46 changes: 43 additions & 3 deletions .github/workflows/checks.yml
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@ jobs:
pip install -r requirements/test.txt
pytest tests/
Test-os:
test-os:
name: Test on ${{ matrix.os }}
runs-on: ${{ matrix.os }}
strategy:
Expand Down Expand Up @@ -116,7 +116,8 @@ jobs:
env:
WHENEVER_NO_BUILD_RUST_EXT: "1"
Linting:
lint:
name: Linting
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
Expand All @@ -135,7 +136,28 @@ jobs:
env:
WHENEVER_NO_BUILD_RUST_EXT: "1"
Typecheck:
check-docstrings:
name: Ensure docstrings in Rust/Python are synced
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: '3.12'
- run: |
pip install .
python generate_docstrings.py | diff -q - docstrings.rs > /dev/null
status=$?
if [ $status -eq 0 ]; then
echo "OK"
else
echo "Rust docstrings are stale. Please run 'python generate_docstrings.py > src/docstrings.rs'"
fi
exit $status
env:
WHENEVER_NO_BUILD_RUST_EXT: "1"
typecheck:
name: Typecheck Python code
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
Expand All @@ -149,3 +171,21 @@ jobs:
make typecheck
env:
WHENEVER_NO_BUILD_RUST_EXT: "1"
# https://github.com/marketplace/actions/alls-green#why
check:
if: always()
needs:
- test-python-versions
- test-os
- test-pure-python
- lint
- check-docstrings
- typecheck
runs-on: ubuntu-latest

steps:
- name: Decide whether the needed jobs succeeded or failed
uses: re-actors/alls-green@release/v1
with:
jobs: ${{ toJSON(needs) }}
12 changes: 12 additions & 0 deletions CHANGELOG.rst
Original file line number Diff line number Diff line change
@@ -1,6 +1,18 @@
🚀 Changelog
============

0.6.14 (2024-11-??)
-------------------

**Fixed**

- Ensure docstrings are consistent in Rust extension
as well as the pure-Python version
- Remove ondocumented properties ``hour/minute/etc`` from ``Instant``
that were accidentally left in the Rust extension.
- ``exact_eq()`` now also raises ``TypeError`` in the pure Python version
when comparing different types.

0.6.13 (2024-11-17)
-------------------

Expand Down
10 changes: 5 additions & 5 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,8 @@ typecheck:

.PHONY: format
format:
black pysrc/ tests/
isort pysrc/ tests/
black pysrc/ tests/ generate_docstrings.py
isort pysrc/ tests/ generate_docstrings.py
cargo fmt

.PHONY: docs
Expand Down Expand Up @@ -40,9 +40,9 @@ test: test-py test-rs

.PHONY: ci-lint
ci-lint: check-readme
flake8 pysrc/ tests/
black --check pysrc/ tests/
isort --check pysrc/ tests/
flake8 pysrc/ tests/ generate_docstrings.py
black --check pysrc/ tests/ generate_docstrings.py
isort --check pysrc/ tests/ generate_docstrings.py
cargo fmt -- --check
env PYTHONPATH=pysrc/ slotscheck pysrc
cargo clippy -- -D warnings
Expand Down
1 change: 0 additions & 1 deletion docs/api.rst
Original file line number Diff line number Diff line change
Expand Up @@ -118,7 +118,6 @@ Deltas

.. autoclass:: whenever.DateTimeDelta
:members:
:undoc-members: date_part, time_part
:special-members: __eq__, __neg__, __abs__, __add__, __sub__, __bool__, __mul__
:member-order: bysource

Expand Down
179 changes: 179 additions & 0 deletions generate_docstrings.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,179 @@
"""This script ensures the Rust extension docstrings are identical to the
Python ones.
It does so by parsing the Python docstrings and generating a Rust file with the
same docstrings. This file is then included in the Rust extension.
"""

import enum
import inspect
from itertools import chain

from whenever import _pywhenever as W

classes = {
cls
for name, cls in W.__dict__.items()
if (
not name.startswith("_")
and inspect.isclass(cls)
and cls.__module__ == "whenever"
and not issubclass(cls, enum.Enum)
)
}
functions = {
func
for name, func in inspect.getmembers(W)
if (
not name.startswith("_")
and inspect.isfunction(func)
and func.__module__ == "whenever"
)
}


methods = {
getattr(cls, name)
for cls in chain(
classes,
(
# some methods are documented in their ABCs
W._BasicConversions,
W._KnowsLocal,
W._KnowsInstant,
W._KnowsInstantAndLocal,
),
)
for name, m in cls.__dict__.items()
if (
not name.startswith("_")
and (
inspect.isfunction(m)
or
# this catches classmethods
inspect.ismethod(getattr(cls, name))
)
)
}


CONST_TEMPLATE = 'pub(crate) const {varname}: &CStr = c"{doc}";'
SIG_TEMPLATE = "{name}({self}, offset=0, /)\\n--\\n\\n{doc}"
HEADER = """\
// Do not manually edit this file.
// It has been autogenerated by generate_docstrings.py
use std::ffi::CStr;
"""

MANUALLY_DEFINED_SIGS: dict[object, str] = {
W.ZonedDateTime.add: """\
($self, delta=None, /, *, years=0, months=0, days=0, hours=0, \
minutes=0, seconds=0, milliseconds=0, microseconds=0, nanoseconds=0, \
disambiguate=None)""",
W.ZonedDateTime.replace: """\
($self, /, *, year=None, month=None, day=None, hour=None, \
minute=None, second=None, nanosecond=None, tz=None, disambiguate)""",
W.OffsetDateTime.add: """\
($self, delta=None, /, *, years=0, months=0, weeks=0, days=0, \
hours=0, minutes=0, seconds=0, milliseconds=0, microseconds=0, nanoseconds=0, \
ignore_dst=False)""",
W.OffsetDateTime.replace: """\
($self, /, *, year=None, month=None, day=None, hour=None, \
minute=None, second=None, nanosecond=None, offset=None, ignore_dst=False)""",
W.LocalDateTime.add: """\
($self, delta=None, /, *, years=0, months=0, days=0, \
hours=0, minutes=0, seconds=0, milliseconds=0, microseconds=0, nanoseconds=0, \
ignore_dst=False)""",
W.LocalDateTime.replace: """\
($self, /, *, year=None, month=None, day=None, hour=None, \
minute=None, second=None, nanosecond=None)""",
W.Date.replace: "($self, /, *, year=None, month=None, day=None)",
W.MonthDay.replace: "($self, /, *, month=None, day=None)",
W.Time.replace: "($self, /, *, hour=None, minute=None, second=None, nanosecond=None)",
W.YearMonth.replace: "($self, /, *, year=None, month=None)",
W.Instant.add: """\
($self, delta=None, /, *, hours=0, minutes=0, seconds=0, \
milliseconds=0, microseconds=0, nanoseconds=0)""",
}
MANUALLY_DEFINED_SIGS.update(
{
W.ZonedDateTime.subtract: MANUALLY_DEFINED_SIGS[W.ZonedDateTime.add],
W.SystemDateTime.add: MANUALLY_DEFINED_SIGS[W.ZonedDateTime.add],
W.SystemDateTime.subtract: MANUALLY_DEFINED_SIGS[W.ZonedDateTime.add],
W.SystemDateTime.replace: MANUALLY_DEFINED_SIGS[
W.ZonedDateTime.replace
],
W.OffsetDateTime.subtract: MANUALLY_DEFINED_SIGS[W.OffsetDateTime.add],
W.LocalDateTime.subtract: MANUALLY_DEFINED_SIGS[W.LocalDateTime.add],
W.Instant.subtract: MANUALLY_DEFINED_SIGS[W.Instant.add],
}
)
SKIP = {
W._BasicConversions.format_common_iso,
W._BasicConversions.from_py_datetime,
W._BasicConversions.parse_common_iso,
W._KnowsInstant.from_timestamp,
W._KnowsInstant.from_timestamp_millis,
W._KnowsInstant.from_timestamp_nanos,
W._KnowsInstant.now,
W._KnowsLocal.add,
W._KnowsLocal.subtract,
W._KnowsLocal.replace,
W._KnowsLocal.replace_date,
W._KnowsLocal.replace_time,
}


def method_doc(method):
method.__annotations__.clear()
try:
sig = MANUALLY_DEFINED_SIGS[method]
except KeyError:
sig = (
str(inspect.signature(method))
# We use unicode escape of '(' to avoid messing up LSP in editors
.replace("\u0028self", "\u0028$self").replace(
"\u0028cls", "\u0028$type"
)
)
doc = method.__doc__.replace("\n", "\\n").replace('"', '\\"')
return f"{method.__name__}{sig}\\n--\\n\\n{doc}"


def print_everything():
print(HEADER)
for cls in sorted(classes, key=lambda x: x.__name__):
assert cls.__doc__
print(
CONST_TEMPLATE.format(
varname=cls.__name__.upper(),
doc=cls.__doc__.replace("\n", "\\n").replace('"', '\\"'),
)
)

for func in sorted(functions, key=lambda x: x.__name__):
assert func.__doc__
print(
CONST_TEMPLATE.format(
varname=func.__name__.upper(),
doc=func.__doc__.replace("\n", "\\n").replace('"', '\\"'),
)
)

for method in sorted(methods, key=lambda x: x.__qualname__):
if method.__doc__ is None or method in SKIP:
continue

qualname = method.__qualname__
if qualname.startswith("_"):
qualname = qualname[1:]
print(
CONST_TEMPLATE.format(
varname=qualname.replace(".", "_").upper(),
doc=method_doc(method),
)
)


if __name__ == "__main__":
print_everything()
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ maintainers = [
{name = "Arie Bovenberg", email = "[email protected]"},
]
readme = "README.md"
version = "0.6.13"
version = "0.6.14"
description = "Modern datetime library for Python"
requires-python = ">=3.9"
classifiers = [
Expand Down
25 changes: 20 additions & 5 deletions pysrc/whenever/_pywhenever.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@
# - It saves some overhead
from __future__ import annotations

__version__ = "0.6.13"
__version__ = "0.6.14"

import enum
import re
Expand Down Expand Up @@ -181,8 +181,8 @@ class Date(_ImmutableBase):
def __init__(self, year: int, month: int, day: int) -> None:
self._py_date = _date(year, month, day)

@staticmethod
def today_in_system_tz() -> Date:
@classmethod
def today_in_system_tz(cls) -> Date:
"""Get the current date in the system's local timezone.
Alias for ``SystemDateTime.now().date()``.
Expand Down Expand Up @@ -1944,9 +1944,11 @@ def __init__(
"""A delta of zero"""

def date_part(self) -> DateDelta:
"""The date part of the delta"""
return self._date_part

def time_part(self) -> TimeDelta:
"""The time part of the delta"""
return self._time_part

def in_months_days_secs_nanos(self) -> tuple[int, int, int, int]:
Expand Down Expand Up @@ -2687,7 +2689,11 @@ def exact_eq(self: _T, other: _T, /) -> bool:
True # equivalent instants
>>> a.exact_eq(b)
False # different values (hour and offset)
>>> a.exact_eq(Instant.now())
TypeError # different types
"""
if type(self) is not type(other):
raise TypeError("Cannot compare different types")
return (
self._py_dt, # type: ignore[attr-defined]
self._py_dt.utcoffset(), # type: ignore[attr-defined]
Expand Down Expand Up @@ -2872,7 +2878,7 @@ def offset(self) -> TimeDelta:
)

def instant(self) -> Instant:
"""Get the underlying instant
"""Get the underlying instant in time
Example
-------
Expand Down Expand Up @@ -5505,11 +5511,20 @@ def nanoseconds(i: int, /) -> TimeDelta:
return TimeDelta(nanoseconds=i)


for name in __all__:
# We expose the public members in the root of the module.
# For clarity, we remove the "_pywhenever" part from the names,
# since this is an implementation detail.
for name in (
__all__ + "_KnowsLocal _KnowsInstant _KnowsInstantAndLocal".split()
):
member = locals()[name]
if getattr(member, "__module__", None) == __name__: # pragma: no branch
member.__module__ = "whenever"

# clear up loop variables so they don't leak into the namespace
del name
del member

for _unpkl in (
_unpkl_date,
_unpkl_ym,
Expand Down
Loading

0 comments on commit 96e4da1

Please sign in to comment.