Skip to content

Commit 3c86758

Browse files
Neko1313Nikita
andauthored
✨ add Cron type (#343)
* added cron type fix test * add examples * add tests json schema --------- Co-authored-by: Nikita <[email protected]>
1 parent 7ec0329 commit 3c86758

File tree

5 files changed

+219
-1
lines changed

5 files changed

+219
-1
lines changed

pydantic_extra_types/cron.py

Lines changed: 134 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,134 @@
1+
"""The `pydantic_extra_types.cron` module provides the [`CronStr`][pydantic_extra_types.cron.CronStr] data type."""
2+
3+
from __future__ import annotations
4+
5+
from datetime import datetime
6+
from typing import TYPE_CHECKING, Any, ClassVar, Protocol, cast
7+
8+
try:
9+
from cron_converter import Cron # type: ignore[import-untyped]
10+
except ModuleNotFoundError as e: # pragma: no cover
11+
raise RuntimeError(
12+
'The `cron` module requires "cron-converter" to be installed. You can install it with "pip install cron-converter".'
13+
) from e
14+
from pydantic import GetCoreSchemaHandler
15+
from pydantic_core import PydanticCustomError, core_schema
16+
17+
if TYPE_CHECKING:
18+
from cron_converter.sub_modules.seeker import Seeker as CronSeeker # type: ignore[import-untyped]
19+
else:
20+
21+
class CronSeeker(Protocol):
22+
def next(self) -> datetime: ...
23+
24+
25+
class CronStr(str):
26+
"""A cron expression validated via [`cron-converter`](https://pypi.org/project/cron-converter/).
27+
## Examples
28+
```python
29+
from pydantic import BaseModel
30+
from pydantic_extra_types.cron import CronStr
31+
32+
class Schedule(BaseModel):
33+
cron: CronStr
34+
35+
schedule = Schedule(cron="*/5 * * * *")
36+
print(schedule.cron)
37+
>> */5 * * * *
38+
print(schedule.cron.minute)
39+
>> */5
40+
print(schedule.cron.next_run)
41+
>> 2025-10-07T22:40:00+00:00
42+
```
43+
"""
44+
45+
strip_whitespace: ClassVar[bool] = True
46+
"""Whether to strip surrounding whitespace from the input value."""
47+
_component_names: ClassVar[tuple[str, ...]] = (
48+
'minute',
49+
'hour',
50+
'day_of_the_month',
51+
'month',
52+
'day_of_the_week',
53+
)
54+
"""Expected cron expression components in the order enforced by `cron-converter`."""
55+
56+
minute: str
57+
hour: str
58+
day_of_the_month: str
59+
month: str
60+
day_of_the_week: str
61+
cron_obj: Cron
62+
63+
def __new__(cls, cron_expression: str, *, _cron: Cron | None = None) -> CronStr:
64+
if _cron is None:
65+
cron_expression, cron_obj = cls._validate(cron_expression)
66+
else:
67+
cron_obj = _cron
68+
cron_expression = cron_obj.to_string()
69+
70+
obj = super().__new__(cls, cron_expression)
71+
obj._apply_cron(cron_obj)
72+
return obj
73+
74+
def _apply_cron(self, cron_obj: Cron) -> None:
75+
self.cron_obj = cron_obj
76+
self.minute, self.hour, self.day_of_the_month, self.month, self.day_of_the_week = str(self).split()
77+
78+
@classmethod
79+
def _validate(cls, value: Any) -> tuple[str, Cron]:
80+
if not isinstance(value, str):
81+
raise PydanticCustomError('cron_str_type', 'Cron expression must be a string')
82+
83+
cron_expression = value.strip()
84+
if not cron_expression:
85+
raise PydanticCustomError('cron_str_empty', 'Cron expression must not be empty')
86+
87+
parts = cron_expression.split()
88+
if len(parts) != len(cls._component_names):
89+
parts_list = ', '.join(cls._component_names)
90+
raise PydanticCustomError(
91+
'cron_str_components',
92+
f'Cron expression must contain {len(cls._component_names)} space separated components: {parts_list}',
93+
)
94+
95+
try:
96+
cron_obj = Cron(cron_expression)
97+
except (TypeError, ValueError) as exc:
98+
raise PydanticCustomError('cron_str_invalid', str(exc)) from exc
99+
100+
# `cron-converter` may normalise components (e.g. remove duplicate spaces),
101+
# so we reuse its canonical representation.
102+
return cron_obj.to_string(), cron_obj
103+
104+
@classmethod
105+
def validate(cls, __input_value: Any, _: core_schema.ValidationInfo) -> CronStr:
106+
cron_expression, cron_obj = cls._validate(__input_value)
107+
return cls(cron_expression, _cron=cron_obj)
108+
109+
@classmethod
110+
def __get_pydantic_core_schema__(cls, source_type: Any, handler: GetCoreSchemaHandler) -> core_schema.CoreSchema:
111+
return core_schema.with_info_after_validator_function(
112+
cls.validate,
113+
core_schema.str_schema(strip_whitespace=cls.strip_whitespace),
114+
)
115+
116+
@classmethod
117+
def __get_pydantic_json_schema__(
118+
cls, schema: core_schema.CoreSchema, handler: GetCoreSchemaHandler
119+
) -> dict[str, Any]:
120+
return dict(handler(schema))
121+
122+
def schedule(self, start_date: datetime | None = None, timezone_str: str | None = None) -> CronSeeker:
123+
"""Return the iterator produced by `cron-converter` for this expression."""
124+
return cast(CronSeeker, self.cron_obj.schedule(start_date=start_date, timezone_str=timezone_str))
125+
126+
def next_after(self, start_date: datetime | None = None, timezone_str: str | None = None) -> datetime:
127+
"""Return the first run datetime after `start_date` (or now if omitted)."""
128+
seeker = self.schedule(start_date=start_date, timezone_str=timezone_str)
129+
return cast(datetime, seeker.next())
130+
131+
@property
132+
def next_run(self) -> str:
133+
"""Return the next run as an ISO formatted string (shortcut for backwards compatibility)."""
134+
return self.next_after().isoformat()

pyproject.toml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,7 @@ all = [
5454
'pytz>=2024.1',
5555
'semver~=3.0.2',
5656
'tzdata>=2024.1',
57+
"cron-converter>=1.2.2",
5758
]
5859
phonenumbers = ['phonenumbers>=8,<10']
5960
pycountry = ['pycountry>=23']
@@ -63,6 +64,7 @@ python_ulid = [
6364
'python-ulid>=1,<4; python_version>="3.9"',
6465
]
6566
pendulum = ['pendulum>=3.0.0,<4.0.0']
67+
cron = ['cron-converter>=1.2.2']
6668

6769
[dependency-groups]
6870
dev = [

tests/test_cron.py

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
from datetime import datetime, timezone
2+
3+
import pytest
4+
from cron_converter import Cron
5+
from pydantic import BaseModel, ValidationError
6+
7+
from pydantic_extra_types.cron import CronStr
8+
9+
10+
class CronModel(BaseModel):
11+
cron: CronStr
12+
13+
14+
def test_cron_str_is_validated_via_model() -> None:
15+
model = CronModel(cron='*/5 0 * * 1-5')
16+
cron_value = model.cron
17+
18+
assert isinstance(cron_value, CronStr)
19+
assert cron_value.minute == '*/5'
20+
assert cron_value.hour == '0'
21+
assert cron_value.day_of_the_month == '*'
22+
assert cron_value.month == '*'
23+
assert cron_value.day_of_the_week == '1-5'
24+
assert isinstance(cron_value.cron_obj, Cron)
25+
26+
27+
def test_cron_str_rejects_invalid_components() -> None:
28+
with pytest.raises(ValidationError) as exc:
29+
CronModel(cron='* * * *')
30+
assert 'Cron expression must contain 5 space separated components' in str(exc.value)
31+
32+
33+
def test_cron_str_rejects_invalid_expression() -> None:
34+
with pytest.raises(ValidationError) as exc:
35+
CronModel(cron='60 0 * * *')
36+
assert "Value 60 out of range for 'minute'" in str(exc.value)
37+
38+
39+
def test_cron_str_next_after() -> None:
40+
cron_value = CronStr('15 8 * * 1-5')
41+
next_run = cron_value.next_after(datetime(2024, 1, 1, 7, 0))
42+
assert next_run == datetime(2024, 1, 1, 8, 15)
43+
current_year = datetime.now(timezone.utc).year
44+
assert cron_value.next_run.startswith(str(current_year)) # sanity check for property access
45+
46+
47+
def test_cron_str_strips_whitespace() -> None:
48+
cron_value = CronStr(' 0 12 * * * ')
49+
assert str(cron_value) == '0 12 * * *'

tests/test_json_schema.py

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
from pydantic_extra_types.color import Color
1111
from pydantic_extra_types.coordinate import Coordinate, Latitude, Longitude
1212
from pydantic_extra_types.country import CountryAlpha2, CountryAlpha3, CountryNumericCode, CountryShortName
13+
from pydantic_extra_types.cron import CronStr
1314
from pydantic_extra_types.currency_code import ISO4217, Currency
1415
from pydantic_extra_types.domain import DomainStr
1516
from pydantic_extra_types.isbn import ISBN
@@ -93,6 +94,20 @@
9394
'type': 'object',
9495
},
9596
),
97+
(
98+
CronStr,
99+
{
100+
'title': 'Model',
101+
'type': 'object',
102+
'properties': {
103+
'x': {
104+
'title': 'X',
105+
'type': 'string',
106+
}
107+
},
108+
'required': ['x'],
109+
},
110+
),
96111
(
97112
CountryAlpha3,
98113
{

uv.lock

Lines changed: 19 additions & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)