Giter Club home page Giter Club logo

croniter's Introduction

croniter provides iteration for the datetime object with a cron like format.

                      _ _
  ___ _ __ ___  _ __ (_) |_ ___ _ __
 / __| '__/ _ \| '_ \| | __/ _ \ '__|
| (__| | | (_) | | | | | ||  __/ |
 \___|_|  \___/|_| |_|_|\__\___|_|

Website: https://github.com/kiorky/croniter

A simple example:

>>> from croniter import croniter
>>> from datetime import datetime
>>> base = datetime(2010, 1, 25, 4, 46)
>>> iter = croniter('*/5 * * * *', base)  # every 5 minutes
>>> print(iter.get_next(datetime))   # 2010-01-25 04:50:00
>>> print(iter.get_next(datetime))   # 2010-01-25 04:55:00
>>> print(iter.get_next(datetime))   # 2010-01-25 05:00:00
>>>
>>> iter = croniter('2 4 * * mon,fri', base)  # 04:02 on every Monday and Friday
>>> print(iter.get_next(datetime))   # 2010-01-26 04:02:00
>>> print(iter.get_next(datetime))   # 2010-01-30 04:02:00
>>> print(iter.get_next(datetime))   # 2010-02-02 04:02:00
>>>
>>> iter = croniter('2 4 1 * wed', base)  # 04:02 on every Wednesday OR on 1st day of month
>>> print(iter.get_next(datetime))   # 2010-01-27 04:02:00
>>> print(iter.get_next(datetime))   # 2010-02-01 04:02:00
>>> print(iter.get_next(datetime))   # 2010-02-03 04:02:00
>>>
>>> iter = croniter('2 4 1 * wed', base, day_or=False)  # 04:02 on every 1st day of the month if it is a Wednesday
>>> print(iter.get_next(datetime))   # 2010-09-01 04:02:00
>>> print(iter.get_next(datetime))   # 2010-12-01 04:02:00
>>> print(iter.get_next(datetime))   # 2011-06-01 04:02:00
>>>
>>> iter = croniter('0 0 * * sat#1,sun#2', base)  # 1st Saturday, and 2nd Sunday of the month
>>> print(iter.get_next(datetime))   # 2010-02-06 00:00:00
>>>
>>> iter = croniter('0 0 * * 5#3,L5', base)  # 3rd and last Friday of the month
>>> print(iter.get_next(datetime))   # 2010-01-29 00:00:00
>>> print(iter.get_next(datetime))   # 2010-02-19 00:00:00

All you need to know is how to use the constructor and the get_next method, the signature of these methods are listed below:

>>> def __init__(self, cron_format, start_time=time.time(), day_or=True)

croniter iterates along with cron_format from start_time. cron_format is min hour day month day_of_week, you can refer to http://en.wikipedia.org/wiki/Cron for more details. The day_or switch is used to control how croniter handles day and day_of_week entries. Default option is the cron behaviour, which connects those values using OR. If the switch is set to False, the values are connected using AND. This behaves like fcron and enables you to e.g. define a job that executes each 2nd Friday of a month by setting the days of month and the weekday.

>>> def get_next(self, ret_type=float)

get_next calculates the next value according to the cron expression and returns an object of type ret_type. ret_type should be a float or a datetime object.

Supported added for get_prev method. (>= 0.2.0):

>>> base = datetime(2010, 8, 25)
>>> itr = croniter('0 0 1 * *', base)
>>> print(itr.get_prev(datetime))  # 2010-08-01 00:00:00
>>> print(itr.get_prev(datetime))  # 2010-07-01 00:00:00
>>> print(itr.get_prev(datetime))  # 2010-06-01 00:00:00

You can validate your crons using is_valid class method. (>= 0.3.18):

>>> croniter.is_valid('0 0 1 * *')  # True
>>> croniter.is_valid('0 wrong_value 1 * *')  # False

Be sure to init your croniter instance with a TZ aware datetime for this to work!

Example using pytz:

>>> import pytz
>>> tz = pytz.timezone("Europe/Paris")
>>> local_date = tz.localize(datetime(2017, 3, 26))
>>> val = croniter('0 0 * * *', local_date).get_next(datetime)

Example using python_dateutil:

>>> import dateutil.tz
>>> tz = dateutil.tz.gettz('Asia/Tokyo')
>>> local_date = datetime(2017, 3, 26, tzinfo=tz)
>>> val = croniter('0 0 * * *', local_date).get_next(datetime)

Example using python built in module:

>>> from datetime import datetime, timezone
>>> local_date = datetime(2017, 3, 26, tzinfo=timezone.utc)
>>> val = croniter('0 0 * * *', local_date).get_next(datetime)

Croniter is able to do second repetition crontabs form and by default seconds are the 6th field:

>>> base = datetime(2012, 4, 6, 13, 26, 10)
>>> itr = croniter('* * * * * 15,25', base)
>>> itr.get_next(datetime) # 4/6 13:26:15
>>> itr.get_next(datetime) # 4/6 13:26:25
>>> itr.get_next(datetime) # 4/6 13:27:15

You can also note that this expression will repeat every second from the start datetime.:

>>> croniter('* * * * * *', local_date).get_next(datetime)

You can also use seconds as first field:

>>> itr = croniter('15,25 * * * * *', base, second_at_beginning=True)

Croniter also support year field. Year presents at the seventh field, which is after second repetition. The range of year field is from 1970 to 2099. To ignore second repetition, simply set second to 0 or any other const:

>>> base = datetime(2012, 4, 6, 2, 6, 59)
>>> itr = croniter('0 0 1 1 * 0 2020/2', base)
>>> itr.get_next(datetime) # 2020 1/1 0:0:0
>>> itr.get_next(datetime) # 2022 1/1 0:0:0
>>> itr.get_next(datetime) # 2024 1/1 0:0:0

See #76, You can set start_time=, then expand_from_start_time=True for your generations to be computed from start_time instead of calendar days:

>>> from pprint import pprint
>>> iter = croniter('0 0 */7 * *', start_time=datetime(2024, 7, 11), expand_from_start_time=True);pprint([iter.get_next(datetime) for a in range(10)])
[datetime.datetime(2024, 7, 18, 0, 0),
 datetime.datetime(2024, 7, 25, 0, 0),
 datetime.datetime(2024, 8, 4, 0, 0),
 datetime.datetime(2024, 8, 11, 0, 0),
 datetime.datetime(2024, 8, 18, 0, 0),
 datetime.datetime(2024, 8, 25, 0, 0),
 datetime.datetime(2024, 9, 4, 0, 0),
 datetime.datetime(2024, 9, 11, 0, 0),
 datetime.datetime(2024, 9, 18, 0, 0),
 datetime.datetime(2024, 9, 25, 0, 0)]
>>> # INSTEAD OF THE DEFAULT BEHAVIOR:
>>> iter = croniter('0 0 */7 * *', start_time=datetime(2024, 7, 11), expand_from_start_time=False);pprint([iter.get_next(datetime) for a in range(10)])
[datetime.datetime(2024, 7, 15, 0, 0),
 datetime.datetime(2024, 7, 22, 0, 0),
 datetime.datetime(2024, 7, 29, 0, 0),
 datetime.datetime(2024, 8, 1, 0, 0),
 datetime.datetime(2024, 8, 8, 0, 0),
 datetime.datetime(2024, 8, 15, 0, 0),
 datetime.datetime(2024, 8, 22, 0, 0),
 datetime.datetime(2024, 8, 29, 0, 0),
 datetime.datetime(2024, 9, 1, 0, 0),
 datetime.datetime(2024, 9, 8, 0, 0)]

Test for a match with (>=0.3.32):

>>> croniter.match("0 0 * * *", datetime(2019, 1, 14, 0, 0, 0, 0))
True
>>> croniter.match("0 0 * * *", datetime(2019, 1, 14, 0, 2, 0, 0))
False
>>>
>>> croniter.match("2 4 1 * wed", datetime(2019, 1, 1, 4, 2, 0, 0)) # 04:02 on every Wednesday OR on 1st day of month
True
>>> croniter.match("2 4 1 * wed", datetime(2019, 1, 1, 4, 2, 0, 0), day_or=False) # 04:02 on every 1st day of the month if it is a Wednesday
False

Test for a match_range with (>=2.0.3):

>>> croniter.match_range("0 0 * * *", datetime(2019, 1, 13, 0, 59, 0, 0), datetime(2019, 1, 14, 0, 1, 0, 0))
True
>>> croniter.match_range("0 0 * * *", datetime(2019, 1, 13, 0, 1, 0, 0), datetime(2019, 1, 13, 0, 59, 0, 0))
False
>>> croniter.match_range("2 4 1 * wed", datetime(2019, 1, 1, 3, 2, 0, 0), datetime(2019, 1, 1, 5, 1, 0, 0))
# 04:02 on every Wednesday OR on 1st day of month
True
>>> croniter.match_range("2 4 1 * wed", datetime(2019, 1, 1, 3, 2, 0, 0), datetime(2019, 1, 1, 5, 2, 0, 0), day_or=False)
# 04:02 on every 1st day of the month if it is a Wednesday
False

For performance reasons, croniter limits the amount of CPU cycles spent attempting to find the next match. Starting in v0.3.35, this behavior is configurable via the max_years_between_matches parameter, and the default window has been increased from 1 year to 50 years.

The defaults should be fine for many use cases. Applications that evaluate multiple cron expressions or handle cron expressions from untrusted sources or end-users should use this parameter. Iterating over sparse cron expressions can result in increased CPU consumption or a raised CroniterBadDateError exception which indicates that croniter has given up attempting to find the next (or previous) match. Explicitly specifying max_years_between_matches provides a way to limit CPU utilization and simplifies the iterable interface by eliminating the need for CroniterBadDateError. The difference in the iterable interface is based on the reasoning that whenever max_years_between_matches is explicitly agreed upon, there is no need for croniter to signal that it has given up; simply stopping the iteration is preferable.

This example matches 4 AM Friday, January 1st. Since January 1st isn't often a Friday, there may be a few years between each occurrence. Setting the limit to 15 years ensures all matches:

>>> it = croniter("0 4 1 1 fri", datetime(2000,1,1), day_or=False, max_years_between_matches=15).all_next(datetime)
>>> for i in range(5):
...     print(next(it))
...
2010-01-01 04:00:00
2016-01-01 04:00:00
2021-01-01 04:00:00
2027-01-01 04:00:00
2038-01-01 04:00:00

However, when only concerned with dates within the next 5 years, simply set max_years_between_matches=5 in the above example. This will result in no matches found, but no additional cycles will be wasted on unwanted matches far in the future.

Find matches within a range using the croniter_range() function. This is much like the builtin range(start,stop,step) function, but for dates. The step argument is a cron expression. Added in (>=0.3.34)

List the first Saturday of every month in 2019:

>>> from croniter import croniter_range
>>> for dt in croniter_range(datetime(2019, 1, 1), datetime(2019, 12, 31), "0 0 * * sat#1"):
>>>     print(dt)

croniter supports Jenkins-style hashed expressions, using the "H" definition keyword and the required hash_id keyword argument. Hashed expressions remain consistent, given the same hash_id, but different hash_ids will evaluate completely different to each other. This allows, for example, for an even distribution of differently-named jobs without needing to manually spread them out.

>>> itr = croniter("H H * * *", hash_id="hello")
>>> itr.get_next(datetime)
datetime.datetime(2021, 4, 10, 11, 10)
>>> itr.get_next(datetime)
datetime.datetime(2021, 4, 11, 11, 10)
>>> itr = croniter("H H * * *", hash_id="hello")
>>> itr.get_next(datetime)
datetime.datetime(2021, 4, 10, 11, 10)
>>> itr = croniter("H H * * *", hash_id="bonjour")
>>> itr.get_next(datetime)
datetime.datetime(2021, 4, 10, 20, 52)

Random "R" definition keywords are supported, and remain consistent only within their croniter() instance.

>>> itr = croniter("R R * * *")
>>> itr.get_next(datetime)
datetime.datetime(2021, 4, 10, 22, 56)
>>> itr.get_next(datetime)
datetime.datetime(2021, 4, 11, 22, 56)
>>> itr = croniter("R R * * *")
>>> itr.get_next(datetime)
datetime.datetime(2021, 4, 11, 4, 19)

Vixie cron-style "@" keyword expressions are supported. What they evaluate to depends on whether you supply hash_id: no hash_id corresponds to Vixie cron definitions (exact times, minute resolution), while with hash_id corresponds to Jenkins definitions (hashed within the period, second resolution).

Keyword No hash_id With hash_id
@midnight 0 0 * * * H H(0-2) * * * H
@hourly 0 * * * * H * * * * H
@daily 0 0 * * * H H * * * H
@weekly 0 0 * * 0 H H * * H H
@monthly 0 0 1 * * H H H * * H
@yearly 0 0 1 1 * H H H H * H
@annually 0 0 1 1 * H H H H * H
  • Install or upgrade pytz by using version specified requirements/base.txt if you have it installed <=2021.1.
git clone https://github.com/kiorky/croniter.git
cd croniter
virtualenv --no-site-packages venv
. venv/bin/activate
pip install --upgrade -r requirements/test.txt
py.test src

We use zest.fullreleaser, a great release infrastructure.

Do and follow these instructions

. venv/bin/activate
pip install --upgrade -r requirements/release.txt
./release.sh

Thanks to all who have contributed to this project! If you have contributed and your name is not listed below please let me know.

  • mrmachine
  • Hinnack
  • shazow
  • kiorky
  • jlsandell
  • mag009
  • djmitche
  • GreatCombinator
  • chris-baynes
  • ipartola
  • yuzawa-san
  • lowell80 (Kintyre)
  • scop
  • zed2015
  • Ryan Finnie (rfinnie)
  • salitaba

croniter's People

Contributors

agateblue avatar bollwyvl avatar cherie0125 avatar cuu508 avatar dark-light-cz avatar djmitche avatar eelkevdbos avatar eumiro avatar hugovk avatar iddoav avatar int3rlop3r avatar jabberwocky-22 avatar josegonzalez avatar kbrose avatar kiorky avatar lowell80 avatar mghextreme avatar mrcrilly avatar otherpirate avatar petervtzand avatar rafsaf avatar rfinnie avatar roderick-jonsson avatar scop avatar sg3-141-592 avatar snapiri avatar sobolevn avatar solartechnologies avatar taichino avatar zed2015 avatar

Stargazers

 avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar

Watchers

 avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar

croniter's Issues

expression not support 6 columns when contain 'L'

when expression as below:
iter = croniter('30 0/20 4 L 1/2 10', base)

here is traceback, because ALPHACONV has key error.
Traceback (most recent call last):
File "D:\temp\202111\t_croniter.py", line 15, in
iter = croniter('30 0/20 4 L 1/2 10', base)
File "D:\odoo\runtime\python3\lib\site-packages\croniter\croniter.py", line 154, in init
self.expanded, self.nth_weekday_of_month = self.expand(expr_format, hash_id=hash_id)
File "D:\odoo\runtime\python3\lib\site-packages\croniter\croniter.py", line 762, in expand
return cls._expand(expr_format, hash_id=hash_id)
File "D:\odoo\runtime\python3\lib\site-packages\croniter\croniter.py", line 711, in _expand
t = cls._alphaconv(i, t, expressions)
File "D:\odoo\runtime\python3\lib\site-packages\croniter\croniter.py", line 166, in _alphaconv
"[{0}] is not acceptable".format(" ".join(expressions)))
croniter.croniter.CroniterNotAlphaError: [30 0/20 4 l 1/2 10] is not acceptable

the code should judge the length of expression when 6 columns, and if true the index should -1.
here is my patch:
@classmethod
def _alphaconv(cls, index, key, expressions):
try:
# wade wang patch
if len(expressions) == 6:
return cls.ALPHACONV[index-1][key]
# end of patch
return cls.ALPHACONV[index][key]
except KeyError:
raise CroniterNotAlphaError(
"[{0}] is not acceptable".format(" ".join(expressions)))

Code review

While considering to use croniter for my own projects I had a closer look at the source code. Things that came to my mind:

  • croniter is currently a god-class anti-pattern. It contains many @classmethod calls and constants, that probably would make more sense as module constants and plain / private functions instead of being part of the class.
  • It might make sense to split it into class CronIter and a constructor function croniter, preserving the same interface as currently presented while sticking with PEP8 naming. All the setup / string parsing code could go to the constructor function then.
  • It's a bit weird to change the return type within an iteration, so the ret_type keyword should be part of the init.
  • get_next has a start_time argument, get_prev doesn't. This should be symmetrical.
  • The code might make use of modern python3 features like f-strings and deprecate python2.
  • using pytest instead of unittest would make the testing code much cleaner.
  • pyproject.toml should replace setup.cfg etc.

If there is any interest in these changes, I'd be happy to provide some patches.

croniter_range may get into infinite loop

The following code snippet would cause the iter to return the same item over and over:

import datetime

from croniter import croniter_range
import pytz


mytz = pytz.timezone('Asia/Hebron')
# Infinite loop
start_time = mytz.localize(datetime.datetime(2022, 3, 26, 0, 0, 0))
stop_time = mytz.localize(datetime.datetime(2022, 3, 27, 0, 0, 0))
ci = croniter_range(start=start_time, stop=stop_time, expr_format="0 0 * * *")
for item in ci:
    print(f'{item}')

This will result in:

2022-03-27 00:00:00+03:00
2022-03-27 00:00:00+03:00
2022-03-27 00:00:00+03:00
2022-03-27 00:00:00+03:00
2022-03-27 00:00:00+03:00
2022-03-27 00:00:00+03:00
2022-03-27 00:00:00+03:00
.
.
.

Other timezones that have the same issue (timezone:datetime):

    "America/Asuncion":"2021-10-03 00:00:00-03:00",
    "America/Havana":"2021-03-14 00:00:00-04:00",
    "America/Santiago":"2021-09-05 00:00:00-03:00",
    "America/Scoresbysund":"2021-03-28 00:00:00+00:00",
    "Asia/Amman":"2021-03-26 00:00:00+03:00",
    "Asia/Beirut":"2021-03-28 00:00:00+03:00",
    "Asia/Damascus":"2021-03-26 00:00:00+03:00",
    "Asia/Gaza":"2021-03-27 00:00:00+03:00",
    "Asia/Hebron":"2021-03-27 00:00:00+03:00",
    "Asia/Tehran":"2021-03-22 00:00:00+04:30",
    "Atlantic/Azores":"2021-03-28 00:00:00+00:00",
    "Chile/Continental":"2021-09-05 00:00:00-03:00",
    "Cuba":"2021-03-14 00:00:00-04:00",
    "Iran":"2021-03-22 00:00:00+04:30"

Croniter skips 2021-03-01 for "0 0 */10 * *" #178

(originally reported in taichino/croniter#178)

Test snippet:

from datetime import datetime
from croniter import croniter

it = croniter("0 0 */10 * *", datetime(2021, 1, 1))

for i in range(0, 15):
    print(it.get_next(datetime).isoformat())

Result:

2021-01-11T00:00:00
2021-01-21T00:00:00
2021-01-31T00:00:00
2021-02-01T00:00:00
2021-02-11T00:00:00
2021-02-21T00:00:00
2021-03-11T00:00:00
2021-03-21T00:00:00
2021-03-31T00:00:00
2021-04-01T00:00:00
2021-04-11T00:00:00
2021-04-21T00:00:00
2021-05-01T00:00:00
2021-05-11T00:00:00
2021-05-21T00:00:00

In the above output, 2021-03-01T00:00:00 is missing.

DST transition behavior

Running the following code under Python 3.8.10 croniter-1.3.7
Note DST starts at 1 AM on 2022-03-12

from datetime import datetime
from croniter import croniter
from dateutil import tz
ny = tz.gettz('America/New_York')
d1=datetime(2022, 3, 12, 22, 0, 30, tzinfo=ny)
it = croniter("0 * * * *", d1)
for x in range(0, 10):
print(it.get_next(ret_type=datetime))

I get:
2022-03-12 23:00:00-05:00
2022-03-13 00:00:00-05:00
2022-03-13 01:00:00-05:00
2022-03-13 01:00:00-05:00
2022-03-13 01:00:00-05:00
2022-03-13 01:00:00-05:00
2022-03-13 01:00:00-05:00
2022-03-13 01:00:00-05:00
03-13 01:00:00-05:00
2022-03-13 01:00:00-05:00

I expected:
2022-03-12 23:00:00-05:00
2022-03-13 00:00:00-05:00
2022-03-13 01:00:00-05:00
2022-03-13 03:00:00-04:00
2022-03-13 04:00:00-04:00
2022-03-13 05:00:00-04:00
2022-03-13 06:00:00-04:00
2022-03-13 07:00:00-04:00
2022-03-13 08:00:00-04:00
2022-03-13 09:00:00-04:00

Timezone aware times give incorrect results

The left is datetime.now(), the right is localized to New York.
US DST changes March 12 and Nov 4th, it looks look that is breaking this expression for March/Nov
Certainly others with L, also tried * * * * L5

0 0 L * *
2023-03-31 00:00:00 2023-03-30 23:00:00-04:00
2023-04-30 00:00:00 2023-03-31 00:00:00-04:00
2023-05-31 00:00:00 2023-04-30 00:00:00-04:00
2023-06-30 00:00:00 2023-05-31 00:00:00-04:00
2023-07-31 00:00:00 2023-06-30 00:00:00-04:00
2023-08-31 00:00:00 2023-07-31 00:00:00-04:00
2023-09-30 00:00:00 2023-08-31 00:00:00-04:00
2023-10-31 00:00:00 2023-09-30 00:00:00-04:00
2023-11-30 00:00:00 2023-10-31 00:00:00-04:00
2023-12-31 00:00:00 2023-11-30 01:00:00-05:00

Croniter mark as valid `10 0 1-5 * 2#2`

The last part #2 is meant to be a comment, this is valid for is_valid

>>> croniter.is_valid('10 0 1-5 * 2#2')
True

but later fail is we try to do a next time with:

>>> from croniter import croniter
>>> from datetime import datetime
>>> base = datetime(2010, 1, 25, 4, 46)
>>> iter = croniter('10 0 1-5 * 2#2', base)
>>> print(iter.get_next(datetime))
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
  File "/home/spk/dev/foundation/testbed/.env/local/lib/python2.7/site-packages/croniter/croniter.py", line 179, in get_next
    return self._get_next(ret_type or self._ret_type, is_prev=False)
  File "/home/spk/dev/foundation/testbed/.env/local/lib/python2.7/site-packages/croniter/croniter.py", line 246, in _get_next
    t1 = self._calc(self.cur, expanded, nth_weekday_of_month, is_prev)
  File "/home/spk/dev/foundation/testbed/.env/local/lib/python2.7/site-packages/croniter/croniter.py", line 527, in _calc
    raise CroniterBadDateError("failed to find next date")
croniter.croniter.CroniterBadDateError: failed to find next date

Most of the online tools mark this as an error.

Screenshot from 2022-05-18 14-27-32

Maybe we should mark it invalid directly on is_valid?

[Q] ERROR 'NoneType' object has no attribute 'lower'

django_q

arrow==1.3.0
asgiref==3.7.2
blessed==1.20.0
certifi==2024.2.2
charset-normalizer==3.3.2
croniter==2.0.2
Django==4.2.11
django-picklefield==3.1
django-q==1.3.9
idna==3.6
python-dateutil==2.9.0.post0
pytz==2024.1
redis==3.5.3
requests==2.31.0
rollbar==1.0.0
six==1.16.0
sqlparse==0.4.4
types-python-dateutil==2.9.0.20240315
typing_extensions==4.10.0
urllib3==2.2.1
uWSGI==2.0.24
wcwidth==0.2.13

Unexpected result when month is "0/{step}"

(originally reported in taichino/croniter#165)

I came across this expression: 0 1 0/10 * *. In human language, I would interpret as to "At 1:00 AM, on every 10th day-of-month, from 0 through 31".

If I generate the next matching datetimes with croniter, here's the results I get:

2021-05-10T01:00:00
2021-05-20T01:00:00
2021-05-30T01:00:00
2021-06-01T01:00:00
2021-06-10T01:00:00
2021-06-20T01:00:00
2021-06-30T01:00:00
2021-07-01T01:00:00
2021-07-10T01:00:00
2021-07-20T01:00:00

Note the timestamps in bold. Obviously, there's no 0th of June or 0th of July. Intuitively, I expected croniter to skip over them, but instead it substituted these dates with 1st of June and 1st of July.

vixie cron does not accept 0 1 0/10 * * at all, it requires day-of-month to be between 1 and 31.

My question is: is this a case of "garbage in, garbage out", or is croniter mimicking some cron implementation other than vixie?

Cron expression with '?' in Day-of-month and Day-of-Week throws CroniterNotAlphaError exception

`from datetime import datetime
from croniter import croniter

it = croniter("0/3 * * * ? *", datetime(2021, 1, 1))
for i in range(0, 15):
print(it.get_next(datetime).isoformat())
`

Result :
Traceback (most recent call last):
File "/home/ubuntu/Desktop/oneQuext/quext-scheduler-engine/venv/lib/python3.10/site-packages/croniter/croniter.py", line 190, in _alphaconv
return cls.ALPHACONV[index][key]
KeyError: '?'

During handling of the above exception, another exception occurred:

Traceback (most recent call last):
File "/home/ubuntu/Desktop/oneQuext/quext-scheduler-sync-engine/test_new_scheduler.py", line 74, in
it = croniter("0/3 * * * ? *", datetime(2021, 1, 1)-timedelta(days=1))
File "/home/ubuntu/Desktop/oneQuext/quext-scheduler-engine/venv/lib/python3.10/site-packages/croniter/croniter.py", line 183, in init
self.expanded, self.nth_weekday_of_month = self.expand(expr_format, hash_id=hash_id)
File "/home/ubuntu/Desktop/oneQuext/quext-scheduler-engine/venv/lib/python3.10/site-packages/croniter/croniter.py", line 807, in expand
return cls._expand(expr_format, hash_id=hash_id)
File "/home/ubuntu/Desktop/oneQuext/quext-scheduler-engine/venv/lib/python3.10/site-packages/croniter/croniter.py", line 749, in _expand
t = cls._alphaconv(i, t, expressions)
File "/home/ubuntu/Desktop/oneQuext/quext-scheduler-engine/venv/lib/python3.10/site-packages/croniter/croniter.py", line 192, in _alphaconv
raise CroniterNotAlphaError(
croniter.croniter.CroniterNotAlphaError: [0/3 * * * ? *] is not acceptable

Unexpected result for "0 0 8-9 * 0#2"

(originally reported in taichino/croniter#177)

I'm working on an experimental cron expression evaluator, and am testing it against croniter. I came across something that might be a bug. The expression:

0 0 8-9 * 0#2

I read it as on midnight of every day that is either:

  • 8th of the month,
  • 9th of the month,
  • the second Sunday of the month

Here's a snippet that generates the first 20 matching datetimes, starting from 2021-01-01:

from datetime import datetime
from croniter import croniter

it = croniter("0 0 8-9 * 0#2", datetime(2021, 1, 1))

for i in range(0, 20):
    print(it.get_next(datetime).isoformat())

And here are the results:

2021-01-10T00:00:00
2021-02-14T00:00:00
2021-03-14T00:00:00
2021-04-11T00:00:00
2021-05-09T00:00:00
2021-06-13T00:00:00
2021-07-11T00:00:00
2021-08-08T00:00:00
2021-09-12T00:00:00
2021-10-10T00:00:00
2021-11-14T00:00:00
2021-12-12T00:00:00
2022-01-09T00:00:00
2022-02-13T00:00:00
2022-03-13T00:00:00
2022-04-10T00:00:00
2022-05-08T00:00:00
2022-06-12T00:00:00
2022-07-10T00:00:00
2022-08-14T00:00:00

Most 8ths and 9ths are missing from the output. I suspect this has something to do with the special handling of #2 in the expression. If I test with "0 0 8-9 * 0", I get the result I expected:

2021-01-03T00:00:00
2021-01-08T00:00:00
2021-01-09T00:00:00
2021-01-10T00:00:00
2021-01-17T00:00:00
2021-01-24T00:00:00
2021-01-31T00:00:00
2021-02-07T00:00:00
2021-02-08T00:00:00
2021-02-09T00:00:00
2021-02-14T00:00:00
2021-02-21T00:00:00
2021-02-28T00:00:00
2021-03-07T00:00:00
2021-03-08T00:00:00
2021-03-09T00:00:00
2021-03-14T00:00:00
2021-03-21T00:00:00
2021-03-28T00:00:00
2021-04-04T00:00:00

croniter.match unexpected output

I'm trying to understand why does this croniter.match call returns True:

    iter = croniter("* * * * * 2,4", datetime(2023, 1, 1, 0, 0, 1))
    print(iter.get_next(datetime))
    print(iter.get_next(datetime))
    print(iter.get_next(datetime))
    print(iter.get_next(datetime))

    print(croniter.match("* * * * * 2,4", datetime(2023, 1, 1, 0, 0, 1)))

Output is:

    2023-01-01 00:00:02
    2023-01-01 00:00:04
    2023-01-01 00:01:02
    2023-01-01 00:01:04
    True

You can see that I'm matching it against a datetime that has 1 in the seconds place.
Is this the indented behavior or is it a bug?

Not Compatible with AWS Cron

sched = '1 3 ? 3,10 SUN *'

this is a valid AWS eventbridge cron
image

but Croniter raise this message:
"errorMessage": "[1 3 ? 3,10 sun *] is not acceptable",

gives any solution to use croniter with aws eventBridge rules?

here the full code:

    now = datetime.datetime.now()
    sched = '1 3 ? 3,10 SUN *'
    cron = croniter.croniter(sched, now)
    for i in range(4):
        nextdate = cron.get_next(datetime.datetime)
        logger.info(nextdate)

Best regards

Checking Crontab Execution within a DateTime Range

This project currently includes a match function designed to test whether a given crontab runs at a specified time. However, I'am now require functionality to determine whether a crontab has executed within a specific datetime range.

.next loses timezone info after v0.3.35 and shows wrong value

Hi thank you for your work on this.

I noticed that calling .next in the way below seems to lose the timezone information after v0.3.35
It also seems to go backwards instead of forward

Any idea why this could be?

from datetime import datetime
import pytz
from croniter import croniter

if __name__ == '__main__':
    now = pytz.timezone('America/New_York').localize(datetime.utcnow())

    cron_schedule = croniter('* * * * *')
	
    print(now)
    print(cron_schedule.next(datetime, now))

v0.3.34

2022-02-15 05:07:31.216199-05:00
2022-02-15 05:08:00-05:00

v0.3.35 / v1.2.0

2022-02-15 10:13:29.604991-05:00
2022-02-15 10:13:00

OSS-Fuzz integration for continuous fuzz testing

Hi,

I was wondering if you would like to integrate continuous fuzzing by way of OSS-Fuzz? Fuzzing is a way to automate test-case generation and has been heavily used for memory unsafe languages. Recently efforts have been put into fuzzing memory safe languages and Python is one of the languages where it would be great to use fuzzing.

In this PR I did an initial integration into OSS-Fuzz. Essentially, OSS-Fuzz is a free service run by Google that performs continuous fuzzing of important open source projects.

If you would like to integrate, the only thing I need is a list of email(s) that will get access to the data produced by OSS-Fuzz, such as bug reports, coverage reports and more stats. Notice the emails affiliated with the project will be public in the OSS-Fuzz repo, as they will be part of a configuration file.

datetime.utcnow and datetime.utcfromtimestamp are deprecated in Python 3.12

datetime.datetime’s utcnow() and utcfromtimestamp() are deprecated and will be removed in a future version. Instead, use timezone-aware objects to represent datetimes in UTC: respectively, call now() and fromtimestamp() with the tz parameter set to datetime.UTC. (Contributed by Paul Ganssle in python/cpython#103857.)

croniter/src/croniter/tests/test_croniter.py
960:            dt = datetime.utcfromtimestamp(c.get_next())
980:            dt = datetime.utcfromtimestamp(c.get_next())
1003:            dt = datetime.utcfromtimestamp(c.get_next())

croniter/src/croniter/croniter.py
230:        result = datetime.datetime.utcfromtimestamp(timestamp)
862:        start, stop = (datetime.datetime.utcfromtimestamp(t) for t in (start, stop))

Last Thursday of the month pattern doesn't work...

def test():
    datetime_str = '23/02/24 00:00:00'
    base = datetime.strptime(datetime_str, '%d/%m/%y %H:%M:%S')
    end_date = base + timedelta(days=31)
    print(
        f'Start time {base.strftime("%d/%m/%Y, %A")}, end time {end_date.strftime("%d/%m/%Y, %A")}')
    testdata = '0 0 24-31 * 4'  # pattern for last Thursday of the month
    for dt in croniter_range(base, end_date, testdata):
        print(dt.strftime("%d/%m/%Y, %A"))

Output:
Start time 23/02/2024, Friday, end time 25/03/2024, Monday
24/02/2024, Saturday
25/02/2024, Sunday
26/02/2024, Monday
27/02/2024, Tuesday
28/02/2024, Wednesday
29/02/2024, Thursday
07/03/2024, Thursday
14/03/2024, Thursday
21/03/2024, Thursday
24/03/2024, Sunday
25/03/2024, Monday

The iterator is return every date that is in the day-of-moth range OR a Thursday.
I want every date that is in the day-of-moth range AND a Thursday.

Exception when parsing "4 0 L/2 2 0"

To reproduce:

>>> from datetime import datetime
>>> from croniter import croniter
>>> croniter("4 0 L/2 2 0", datetime.now())
Traceback (most recent call last):
  File "/home/user/venvs/croniter-fuzzing/lib/python3.10/site-packages/croniter/croniter.py", line 778, in expand
    return cls._expand(expr_format, hash_id=hash_id)
  File "/home/user/venvs/croniter-fuzzing/lib/python3.10/site-packages/croniter/croniter.py", line 691, in _expand
    not low or not high or int(low) > int(high)
ValueError: invalid literal for int() with base 10: 'l'

During handling of the above exception, another exception occurred:

Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
  File "/home/user/venvs/croniter-fuzzing/lib/python3.10/site-packages/croniter/croniter.py", line 166, in __init__
    self.expanded, self.nth_weekday_of_month = self.expand(expr_format, hash_id=hash_id)
  File "/home/user/venvs/croniter-fuzzing/lib/python3.10/site-packages/croniter/croniter.py", line 785, in expand
    exec("raise CroniterBadCronError from  exc", globs, locs)
  File "<string>", line 1, in <module>
NameError: name 'exc' is not defined. Did you mean: 'exec'?

Croniter `get_prev` fails on leap year days (like today)

Croniter get_prev fails on leap year days (like today):

import croniter
import datetime

croniter.croniter("15 22 29 2 *", datetime.datetime(2024, 2, 29)).get_prev(datetime.datetime)
# raises: croniter.croniter.CroniterBadDateError: failed to find prev date

Error parsing step-day-of-month and day-of-week

Hello. Thank you very much for this library. It seems to be working well, except for this odd behavior:

import croniter, datetime

print('\n----- 16:00 every Saturday')
iterobj = croniter.croniter('0 16 * * SAT', datetime.datetime(2023, 5, 1))
for i in range(0, 8):
    print(iterobj.get_next(datetime.datetime).strftime("%Y-%m-%d %H:%M:%S (%A)"))

print('\n----- 16:00 every other day (1st, 3rd, 5th, etc.)')
iterobj = croniter.croniter('0 16 */2 * *', datetime.datetime(2023, 5, 1))
for i in range(0, 8):
    print(iterobj.get_next(datetime.datetime).strftime("%Y-%m-%d %H:%M:%S (%A)"))

print('\n----- 16:00 every other day (1st, 3rd, 5th, etc.) but only if the day is a Saturday')
iterobj = croniter.croniter('0 16 */2 * SAT', datetime.datetime(2023, 5, 1))
for i in range(0, 8):
    print(iterobj.get_next(datetime.datetime).strftime("%Y-%m-%d %H:%M:%S (%A)"))

Output is:

----- 16:00 every Saturday
2023-05-06 16:00:00 (Saturday)
2023-05-13 16:00:00 (Saturday)
2023-05-20 16:00:00 (Saturday)
2023-05-27 16:00:00 (Saturday)
2023-06-03 16:00:00 (Saturday)
2023-06-10 16:00:00 (Saturday)
2023-06-17 16:00:00 (Saturday)
2023-06-24 16:00:00 (Saturday)

----- 16:00 every other day (1st, 3rd, 5th, etc.)
2023-05-01 16:00:00 (Monday)
2023-05-03 16:00:00 (Wednesday)
2023-05-05 16:00:00 (Friday)
2023-05-07 16:00:00 (Sunday)
2023-05-09 16:00:00 (Tuesday)
2023-05-11 16:00:00 (Thursday)
2023-05-13 16:00:00 (Saturday)
2023-05-15 16:00:00 (Monday)

----- 16:00 every other day (1st, 3rd, 5th, etc.) but only if the day is a Saturday
2023-05-01 16:00:00 (Monday)
2023-05-03 16:00:00 (Wednesday)
2023-05-05 16:00:00 (Friday)
2023-05-06 16:00:00 (Saturday)
2023-05-07 16:00:00 (Sunday)
2023-05-09 16:00:00 (Tuesday)
2023-05-11 16:00:00 (Thursday)
2023-05-13 16:00:00 (Saturday)

The first two are correct, but the third one goes a little bonkers. The expected output would be job listings for 5/13, 5/27, 6/3, 6/17, etc. (every Saturday that is an odd number day), but croniter seems a little confused.

I wonder if croniter is using OR logic ("Run this program every other day OR if the day is a Saturday") when it should really be AND logic ("Run this program every other day if that day is also a Saturday")

Thanks for looking into it, and for your work on this tool.

"30 23 1-7 JAN MON-FRI#1" - not valid in version 1.3.8

croniter==1.3.8

>>> from croniter import croniter
>>> croniter.is_valid("30 6 1-7 MAY MON#1")
True
>>> croniter.is_valid("30 23 1-7 JAN MON-FRI#1")
False

in the version 0.3.37 it worked
croniter==0.3.37

>>> from croniter import croniter
>>> croniter.is_valid("30 6 1-7 MAY MON#1")
True
>>> croniter.is_valid("30 23 1-7 JAN MON-FRI#1")
True

seconds field is conventionally at the front

As far as I gather the seconds field in a cron expression (if it exists at all) is conventionally the first field. This is even described by the Wikipedia article mentioned in the docs. However, for croniter it seems to be the last field instead. Most importantly I think this should be stated explicitly in the docs. Additionally, it would be nice to see it moved to the front, though this would be a breaking change (major version bump?) and it might not be straightforward given how the parsing is currently implemented.

future incompatibility with Python 3.12

Homeassitant core installation, after upgrade to Python 3.12:

2024-03-14 18:54:49.684 ERROR (MainThread) [homeassistant.config] Setup of package 'utility_meter' at packages/Energy/utility_meter.yaml, line 1 failed: Integration utility_meter caused error: No module named 'future'

From Gentoo's package.mask:

- dev-python/future-0.18.3::gentoo (masked by: package.mask)
/var/db/repos/gentoo/profiles/package.mask:
# Eli Schwartz <[email protected]> (2024-03-14)
# Deprecated, doesn't work with python 3.12. Unmaintained with last
# serious release in 2019, and another release in 2024 that claims
# to support python 3.12 but fails tests if you actually do. No
# route to making it work with python 3.13 at all, and upstream
# explicitly states that future "like Python 2, is now done". The
# package is entirely redundant to packages not supporting python
# 2 anymore, and the ones that do support it should be using
# dev-python/six instead, anyways.  Bug #888271
# Removal on 2024-04-13.

Provide option for `.get_next()` and `.get_prev()` to include current

Currently, get_next and get_prev do not account for whether the current time is a match for the cron. They strictly mean next and previous exclusive of the current time -

>>> from croniter import croniter
>>> from datetime import datetime
>>> croniter(" 0 5 * * *", datetime(2020, 1, 1, 5, 0, 0)).get_next(datetime)
datetime.datetime(2020, 1, 2, 5, 0)
>>> croniter(" 0 5 * * *", datetime(2020, 1, 1, 5, 0, 0)).get_prev(datetime)
datetime.datetime(2019, 12, 31, 5, 0)

This is reasonable behaviour but I think it would be better if we can give the users a choice to include the current time as well. So perhaps an extra argument like -

>>> from croniter import croniter
>>> from datetime import datetime
>>> croniter(" 0 5 * * *", datetime(2020, 1, 1, 5, 0, 0)).get_next(datetime, incl_current=True)
datetime.datetime(2020, 1, 1, 5, 0)
>>> croniter(" 0 5 * * *", datetime(2020, 1, 1, 5, 0, 0)).get_prev(datetime, incl_current=True)
datetime.datetime(2020, 1, 1, 5, 0)

I've noticed users subtracting one second from the current time to make it inclusive or using croniter.match with current time first then calling on get_next or get_prev which works but is a bit more verbose. Neither of these options seem very ideal.

`*/90 * * * *` resolves to an hourly schedule

>>> it = croniter('*/90 * * * *')
>>> it.get_next(datetime).isoformat()
'2023-07-14T17:00:00'
>>> it.get_next(datetime).isoformat()
'2023-07-14T18:00:00'
>>> it.get_next(datetime).isoformat()
'2023-07-14T19:00:00'

Possible bug

Hi
This is a question for now but it might be a bug.
I use croniter to generate events based on a crontab expression.
For the crontab: "0 20 * * *" (The time here is considered localized to EST).
When executed for a start date of: 2024-03-08 03:06:28 (UTC). That was converted from 11:06 p.m. EST and the tzinfo was removed.
After calling the get_next (whitout the start_time), the time stamps are converted back to tzinfo=UTC and then astimezone(pytz.timezone('America/Toronto') returns datetime.datetime for the hours in the same date: 20:00, 21:00, 19:00 (these are EST)
The expected was only: 20:00
After setting the time to 2024-03-08 00:00:00 and run the same, it returns the expected value. Without additional events in the same days.
I observed the behaviour for the events generated for March 10 and 11.
Is there any special condition that opens the get_next to generate more than one event per day for the "0 20 * * *"?
Thank you!

Latest version is not working as expected - fails loading a proper cron_schedule string

Latest version is not working as expected. Fails loading a proper cron_schedule string:


  File "/opt/dagster/dagster_home/<PROJECT_NAME>/schedules/stores_schedule.py", line 13, in <module>
    @schedule(name='stores_schedule', job=SCHEDULED_JOB, cron_schedule="0 0 * * 6", execution_timezone=run_config.constants.EXECUTION_TIMEZONE)
     ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/opt/conda/envs/dagster_env/lib/python3.11/site-packages/dagster/_core/definitions/decorators/schedule_decorator.py", line 174, in inner
    schedule_def = ScheduleDefinition(
                   ^^^^^^^^^^^^^^^^^^^
  File "/opt/conda/envs/dagster_env/lib/python3.11/site-packages/dagster/_core/definitions/schedule_definition.py", line 538, in __init__
    if not is_valid_cron_schedule(self._cron_schedule):  # type: ignore
           ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/opt/conda/envs/dagster_env/lib/python3.11/site-packages/dagster/_utils/schedules.py", line 22, in is_valid_cron_schedule
    is_valid_cron_string(cron_schedule)
  File "/opt/conda/envs/dagster_env/lib/python3.11/site-packages/dagster/_utils/schedules.py", line 15, in is_valid_cron_string
    expanded, _ = croniter.expand(cron_string)
    ^^^^^^^^^^^

`get_next` behaviour seems to have changed in 1.3.0

Hi, i'm using croniter through Procrastinate, an asynchronous task scheduler and executor. Procrastinate relies on croniter to trigger recurring jobs.

My recurring jobs have stopped triggering today, and I've pinpointed the issue to this croniter commit: 1ea781a

If I run my procrastinate worker with this croniter version, recurring jobs are not triggered. If I run my procrastinate worker with commit 5298c6a (the previous one), everything works as expected.

Internally, Procrastinate uses the get_next() method to decide how much time to wait before trigerring a recurring job. This is the method that was modified in 1ea781a.

Do you you think 1ea781a could have introduced a backward incompatible change, or does the issue lies in Procrastinate itself (latest release was on 2021-12-19)?

Request to be added to Croniter OSS-Fuzzer project members

Hi there @kiorky ,

I've been improving the coverage on the Google OSS-Fuzz integration for Croniter google/oss-fuzz#9860 . See https://storage.googleapis.com/oss-fuzz-introspector/croniter/inspector-report/20230317/fuzz_report.html for current status.

There's some issues with the updates to the fuzzer. Currently the only people who can see the stack traces for the errors are people listed in this file https://github.com/google/oss-fuzz/blob/master/projects/croniter/project.yaml . Would you mind me adding my e-mail address to this file? I need a maintainers permissions to be added to it to debug what's wrong.

Thanks for the library, it's been great for building some scheduled start/stop VM cost optimisation work recently.

Document precision for croniter.match

Found out that croniter.match will return true if the input date is less than 60 or 1 seconds from the match (depending if a 5 or 6 length cron).

From the documentation it would seem like match is exact.

I would suggest that the precision can be optionally passed in to the function and the docs updated to make it clear that match takes a precision. Happy to help put a PR together if you agree.

hashed expression skips time

With version 1.3.8, and providing hash_id='lml-scamper', croniter with H/5 * * * * operates like */5 * * * * and skips minute zero.

Wish I could say more but that's all I've been able to figure out so far. (Other hash_ids I've tried appear to work fine.)

>>> itr = croniter('H/5 * * * *', datetime(2010, 1, 1, 10), ret_type=datetime, hash_id='lml-scamper')

>>> for i in range(15):
...     print(next(itr))
... 
2010-01-01 10:05:00
2010-01-01 10:10:00
2010-01-01 10:15:00
2010-01-01 10:20:00
2010-01-01 10:25:00
2010-01-01 10:30:00
2010-01-01 10:35:00
2010-01-01 10:40:00
2010-01-01 10:45:00
2010-01-01 10:50:00
2010-01-01 10:55:00
2010-01-01 11:05:00
2010-01-01 11:10:00
2010-01-01 11:15:00
2010-01-01 11:20:00

License info

Hello. This isn't really an "issue" but I was wondering if this software has any specific license. I've looked through the repository and didn't seem to find anything.

Thank you.

Uppercase `SHA256=` in packaging causes `mamba`/`conda` `AssertionError`

I'm not exactly sure where it is happening, but with the release of 2.0.0 I am getting an AssertionError after installing croniter==2.0.0 inside a conda environment using pip, and it seems to be unhappy that the package hash starts with SHA256= rather than sha256=. Was there a change in the croniter packaging system between 1.4.1 and 2.0.0?

Or maybe this makes more sense to one of the conda feedstock maintainers @mariusvniekerk @bollwyvl? Or maybe it's a problem with how mamba/conda is parsing the package metadata? I'm unsure.

To reproduce the error:

mamba create -n "croniter-test" python=3.11
mamba activate croniter-test
pip install "croniter==2.0.0"
mamba list
    Traceback (most recent call last):
      File "/Users/zane/mambaforge/lib/python3.10/site-packages/conda/exceptions.py", line 1124, in __call__
        return func(*args, **kwargs)
      File "/Users/zane/mambaforge/lib/python3.10/site-packages/mamba/mamba.py", line 945, in exception_converter
        raise e
      File "/Users/zane/mambaforge/lib/python3.10/site-packages/mamba/mamba.py", line 938, in exception_converter
        exit_code = _wrapped_main(*args, **kwargs)
      File "/Users/zane/mambaforge/lib/python3.10/site-packages/mamba/mamba.py", line 884, in _wrapped_main
        result = do_call(parsed_args, p)
      File "/Users/zane/mambaforge/lib/python3.10/site-packages/mamba/mamba.py", line 752, in do_call
        exit_code = getattr(module, func_name)(args, parser)
      File "/Users/zane/mambaforge/lib/python3.10/site-packages/conda/cli/main_list.py", line 137, in execute
        exitcode = print_packages(prefix, regex, format, piplist=args.pip,
      File "/Users/zane/mambaforge/lib/python3.10/site-packages/conda/cli/main_list.py", line 76, in print_packages
        exitcode, output = list_packages(prefix, regex, format=format,
      File "/Users/zane/mambaforge/lib/python3.10/site-packages/conda/cli/main_list.py", line 42, in list_packages
        installed = sorted(PrefixData(prefix, pip_interop_enabled=True).iter_records(),
      File "/Users/zane/mambaforge/lib/python3.10/site-packages/conda/core/prefix_data.py", line 136, in iter_records
        return iter(self._prefix_records.values())
      File "/Users/zane/mambaforge/lib/python3.10/site-packages/conda/core/prefix_data.py", line 165, in _prefix_records
        return self.__prefix_records or self.load() or self.__prefix_records
      File "/Users/zane/mambaforge/lib/python3.10/site-packages/conda/common/io.py", line 84, in decorated
        return f(*args, **kwds)
      File "/Users/zane/mambaforge/lib/python3.10/site-packages/conda/core/prefix_data.py", line 75, in load
        self._load_site_packages()
      File "/Users/zane/mambaforge/lib/python3.10/site-packages/conda/core/prefix_data.py", line 282, in _load_site_packages
        python_record = read_python_record(self.prefix_path, af, python_pkg_record.version)
      File "/Users/zane/mambaforge/lib/python3.10/site-packages/conda/gateways/disk/read.py", line 245, in read_python_record
        paths_tups = pydist.get_paths()
      File "/Users/zane/mambaforge/lib/python3.10/site-packages/conda/common/pkg_formats/python.py", line 265, in get_paths
        records = process_csv_row(record_reader)
      File "/Users/zane/mambaforge/lib/python3.10/site-packages/conda/common/pkg_formats/python.py", line 248, in process_csv_row
        assert checksum.startswith('sha256='), (self._metadata_dir_full_path,
    AssertionError: ('/Users/zane/mambaforge/envs/croniter-test/lib/python3.11/site-packages/croniter-2.0.0.dist-info', 'lib/python3.11/site-packages/croniter-2.0.0.dist-info/LICENSE', 'SHA256=qPlS5sa7MLDPz3HDBOOTEWDSqbV41u3fpcwYyDLhftM')

Incorrect validation of cron time

Hello, everyone. I found a bug at work and it is related to the validation of the cron expression. I have 0 days and 0 months, but your method says "True". Can you explain to me if i'm wrong?
image

Weird behavior with end of DST and get_next method

Hello,

I got a weird behavior with the end of DST (Daily Saving Time) when croniter computes next occurrences.

With timezone Europe/Paris, at 2022-10-30T03:00:00+02:00, it will be 2022-10-30T02:00:00+01:00.
In this example, I simulate a date before the DST (midnight)

with freeze_time("2022-10-30T00:00:00+02:00"):
    now = datetime.now(tz=timezone.utc).astimezone(ZoneInfo("Europe/Paris"))
    cron = croniter("00 20 * * *", now)

    next1 = cron.get_next(datetime)
    next2 = cron.get_next(datetime)
    next3 = cron.get_next(datetime)
  
    print("Next1=", next1)
    print("Next2=", next2)
    print("Next3=", next3)

# Next1= 2022-10-30 20:00:00+01:00
# Next2= 2022-10-31 21:00:00+01:00  <-- 21H instead of 20H
# Next3= 2022-11-01 20:00:00+01:00

It seems the behavior come from this PR taichino/croniter#92

I think there is a bug, what is the expected result for this example ?

Folding time when transitioning out of DST is not considered

When a DST period exits, usually the wall clock time would “fold” because it is turned back, causing a period to “happen” twice. For example, for 2023 in Switzerland, DST ends (from offset +2 to +1) on 29 Oct at wall clock time 3am, so the [2:00, 3:00) period of the day would occur twice before 3am is reached. This is indicated by the fold attribute on datetime in Python 3.6 onward.

The attribute, however, does not seem to be considered correctly, as shown by the following example:

from datetime import datetime
from zoneinfo import ZoneInfo
from croniter import croniter

it = croniter("*/5 * * * *")  # Every 5 minutes

d1 = datetime(2023, 10, 29, 2, 55, fold=0, tzinfo=ZoneInfo("Europe/Zurich"))
print(d1)  # 2023-10-29 02:55:00+02:00

it.set_current(d1)
d2 = it.get_next(datetime)

print(d2)  # 2023-10-29 03:00:00+01:00
# Wrong! We were in the non-folded 2:55, the next run should be folded to 2:00+01.

print((d2.timestamp() - d1.timestamp()) / 60)  # 65.0, an entire hour is skipped.

Similarly, when DST ended but we’re still in fold…

d3 = datetime(2023, 10, 29, 2, 30, fold=1, tzinfo=ZoneInfo("Europe/Zurich"))
print(d3)  # 2023-10-29 02:30:00+01:00

it.set_current(d3)
d4 = it.get_next(datetime)

print(d4)  # 2023-10-29 02:35:00+02:00
# We already exited DST, now this takes us back in it!

print((d4.timestamp() - d3.timestamp()) / 60)  # -55.0, we travelled back in time.

Making croniter consistent with buggy Vixie/ISC/cronie crontabs

Hello. Thank you very much for this library.

There is a 30+ year old bug in Vixie Cron (and ISC cron and cronie and probably most other implementations) in how asterisks are parsed for day-of-month and day-of-week entries. Officially, the crontab(5) man page says:

Note: The day of a command's execution can be specified by two fields — day of month, and day of week. If both fields are restricted (i.e., aren't *), the command will be run when either field matches the current time. For example,
30 4 1,15 * 5 would cause a command to be run at 4:30 am on the 1st and 15th of each month, plus every Friday.

So when this code is run:

import croniter, datetime
entry = '0 16 1,2,3 * FRI'
iterobj = croniter.croniter(entry, datetime.datetime(2023, 5, 1))
for i in range(0, 10):
    print(iterobj.get_next(datetime.datetime).strftime("%Y-%m-%d %H:%M:%S (%A)"))

Croniter prints out the correct dates: 4:00pm on May 1, May 2, May 3, plus every Friday

2023-05-01 16:00:00 (Monday)
2023-05-02 16:00:00 (Tuesday)
2023-05-03 16:00:00 (Wednesday)
2023-05-05 16:00:00 (Friday)
2023-05-12 16:00:00 (Friday)
2023-05-19 16:00:00 (Friday)
2023-05-26 16:00:00 (Friday)
2023-06-01 16:00:00 (Thursday)
2023-06-02 16:00:00 (Friday)
2023-06-03 16:00:00 (Saturday)

The bug in cron, however, is that it fails to parse day-of-month and day-of-week fields correctly if they contain an asterisk plus other characters, such as */2. The code below looks like it ought to indicate "4pm every other day (i.e. odd-numbered days), plus every Friday":

import croniter, datetime
entry = '0 16 */2 * FRI'
iterobj = croniter.croniter(entry, datetime.datetime(2023, 5, 1))
for i in range(0, 10):
    print(iterobj.get_next(datetime.datetime).strftime("%Y-%m-%d %H:%M:%S (%A)"))

And that's what croniter thinks, too:

2023-05-01 16:00:00 (Monday)
2023-05-03 16:00:00 (Wednesday)
2023-05-05 16:00:00 (Friday)
2023-05-07 16:00:00 (Sunday)
2023-05-09 16:00:00 (Tuesday)
2023-05-11 16:00:00 (Thursday)
2023-05-12 16:00:00 (Friday)
2023-05-13 16:00:00 (Saturday)
2023-05-15 16:00:00 (Monday)
2023-05-17 16:00:00 (Wednesday)

but due to the bug, the actual parsed schedule on UNIX/Linux/Macs will be "4pm every other day (i.e. odd-numbered days), but only if that day is a Friday". So the job will actually only run on May 5 (an odd-numbered Friday), not May 20, then again on May 27 and June 3, not June 10, etc.

(more details on the asterisk bug in cron is here: https://crontab.guru/cron-bug.html , and the consensus seems to be that this bug should not be fixed due to its having been around for decades now.)

So given that this bug in most every cron exists and is not going away, could croniter be updated to recognize what cron will actually do with entries like "0 16 */2 * FRI" ?

Or alternatively, if you don't want to change the default processing rules croniter already uses, could a implement_cron_bug flag be passed into the croniter class that will produce the buggy (but real-world) output?

Thanks again for your work on the tool.

Croniter expression "* * 5 3 1-7" does not match weeks

I want to match March 5th or Monday to Sunday in March, so I used the expression * * 5 3 1-7, but the expression optimization will convert the expression to * * 5 3 *, so that only Match March 5th. Let's look at another example, * * 5 3 1-6 is correct, the expression will be converted to * * 5 3 1,2,3,4,5,6, so it will not only match March 5 , it can also match every day in March except Sunday.

TypeError: issubclass() arg 1 must be a class

I run the folllowing code and get an error -

from croniter import croniter

import datetime

date = datetime.datetime.now() + datetime.timedelta(days=1)
cron_expression =  "* * * * *"
cron = croniter(cron_expression, date)

next = cron.get_next(datetime)

print(next)

Error -

Traceback (most recent call last):
  File "minimal_example.py", line 9, in <module>
    next = cron.get_next(datetime)
           ^^^^^^^^^^^^^^^^^^^^^^^
  File "<***>python3.12/site-packages/croniter/croniter.py", line 201, in get_next
    return self._get_next(ret_type or self._ret_type, is_prev=False)
           ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "<***>/python3.12/site-packages/croniter/croniter.py", line 260, in _get_next
    if not issubclass(ret_type, (float, datetime.datetime)):
           ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
TypeError: issubclass() arg 1 must be a class

I believe it relates to python version update since it used to work on 3.11

Python version - Python 3.12.1
croniter version - 2.0.1

`croniter.match` performs unexpectedly

Forgive me if I'm misunderstanding something, but it appears that the .match() method does not behave correctly:

image

For minute-level schedules like 0 * * * * *, it seems like for 58 seconds after the expression matches the true time-of-day , the .match() method will still return True

croniter.match('0-3 * * * * *', datetime(2022, 10, 31, 3, 4, 58))  # Should be False, but evaluates to True

Division by zero possible with ranges

Division by zero possible with ranges

The oss-fuzz croniter project integration originally found an out of range exception with this string.

"""0 r(1-0)
r
*
0"""

This issue is around the r(1-0) section

Some other reproductions of the issue

0 r(1-0) * * * *
H(30-29) H * * *

You can force a modulo zero in croniter.py:894 by setting range_end to 1 less than range_begin

crc = binascii.crc32(hash_id) & 0xFFFFFFFF
return ((crc >> idx) % (range_end - range_begin + 1)) + range_begin

This doesn't affect when there's a division e.g. H(30-29)/2 H * * * works fine

The library doesn't produce valid results when range_end is less than range_begin. Having a look, it seems like the library could raise a Bad Cron Error to the elif m['range_begin'] and m['range_end']: section in expand.

Happy to raise a PR and add a new unit test for this if you think it's the right approach.

Recommend Projects

  • React photo React

    A declarative, efficient, and flexible JavaScript library for building user interfaces.

  • Vue.js photo Vue.js

    🖖 Vue.js is a progressive, incrementally-adoptable JavaScript framework for building UI on the web.

  • Typescript photo Typescript

    TypeScript is a superset of JavaScript that compiles to clean JavaScript output.

  • TensorFlow photo TensorFlow

    An Open Source Machine Learning Framework for Everyone

  • Django photo Django

    The Web framework for perfectionists with deadlines.

  • D3 photo D3

    Bring data to life with SVG, Canvas and HTML. 📊📈🎉

Recommend Topics

  • javascript

    JavaScript (JS) is a lightweight interpreted programming language with first-class functions.

  • web

    Some thing interesting about web. New door for the world.

  • server

    A server is a program made to process requests and deliver data to clients.

  • Machine learning

    Machine learning is a way of modeling and interpreting data that allows a piece of software to respond intelligently.

  • Game

    Some thing interesting about game, make everyone happy.

Recommend Org

  • Facebook photo Facebook

    We are working to build community through open source technology. NB: members must have two-factor auth.

  • Microsoft photo Microsoft

    Open source projects and samples from Microsoft.

  • Google photo Google

    Google ❤️ Open Source for everyone.

  • D3 photo D3

    Data-Driven Documents codes.