Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Ensure docstrings consistent between Rust/Python #188

Merged
merged 1 commit into from
Nov 27, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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 = "[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
Loading