Skip to content

Commit

Permalink
ensure docstrings and error messages 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 abdc3f5
Show file tree
Hide file tree
Showing 26 changed files with 2,618 additions and 746 deletions.
48 changes: 45 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,29 @@ 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.13'
- run: |
pip install .
python generate_docstrings.py > fresh_docstrings.rs
if diff -q fresh_docstrings.rs src/docstrings.rs > /dev/null; then
echo "OK"
else
echo "Rust docstrings are stale. Please run 'python generate_docstrings.py > src/docstrings.rs'";
# output the actual diff
diff -u fresh_docstrings.rs src/docstrings.rs
exit 1
fi
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 +172,22 @@ jobs:
make typecheck
env:
WHENEVER_NO_BUILD_RUST_EXT: "1"
# https://github.com/marketplace/actions/alls-green#why
all-green:
name: Are all checks green?
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-27)
-------------------

**Fixed**

- Ensure docstrings and error messages 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
194 changes: 194 additions & 0 deletions generate_docstrings.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,194 @@
"""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
import sys
from itertools import chain

from whenever import _pywhenever as W

assert sys.version_info >= (
3,
13,
), "This script requires Python 3.13 or later due to how docstrings are rendered."

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))
)
)
}

MAGIC_STRINGS = {
(name, value)
for name, value in W.__dict__.items()
if isinstance(value, str) and name.isupper() and not name.startswith("_")
}

CSTR_TEMPLATE = 'pub(crate) const {varname}: &CStr = c"\\\n{doc}";'
STR_TEMPLATE = 'pub(crate) const {varname}: &str = "{value}";'
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('"', '\\"')
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(
CSTR_TEMPLATE.format(
varname=cls.__name__.upper(),
doc=cls.__doc__.replace('"', '\\"'),
)
)

for func in sorted(functions, key=lambda x: x.__name__):
assert func.__doc__
print(
CSTR_TEMPLATE.format(
varname=func.__name__.upper(),
doc=func.__doc__.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(
CSTR_TEMPLATE.format(
varname=qualname.replace(".", "_").upper(),
doc=method_doc(method),
)
)

for name, value in sorted(MAGIC_STRINGS):
print(STR_TEMPLATE.format(varname=name, value=value))


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 = "a.c.bovenberg@gmail.com"},
]
readme = "README.md"
version = "0.6.13"
version = "0.6.14"
description = "Modern datetime library for Python"
requires-python = ">=3.9"
classifiers = [
Expand Down
Loading

0 comments on commit abdc3f5

Please sign in to comment.