aws-croniter 0.1.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- aws_croniter-0.1.0/LICENSE +21 -0
- aws_croniter-0.1.0/PKG-INFO +72 -0
- aws_croniter-0.1.0/README.md +53 -0
- aws_croniter-0.1.0/pyproject.toml +60 -0
- aws_croniter-0.1.0/src/aws_croniter/__init__.py +0 -0
- aws_croniter-0.1.0/src/aws_croniter/awscron.py +264 -0
- aws_croniter-0.1.0/src/aws_croniter/exceptions.py +40 -0
- aws_croniter-0.1.0/src/aws_croniter/occurrence.py +173 -0
- aws_croniter-0.1.0/src/aws_croniter/utils.py +165 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2024 Siddarth Patil
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
Metadata-Version: 2.1
|
|
2
|
+
Name: aws-croniter
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: A Python utility for AWS cron expressions. Validate and parse AWS EventBridge cron expressions seamlessly.
|
|
5
|
+
License: MIT
|
|
6
|
+
Author: Siddarth Patil
|
|
7
|
+
Author-email: siddarth3639@gmail.com
|
|
8
|
+
Requires-Python: >=3.8,<4.0
|
|
9
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
10
|
+
Classifier: Programming Language :: Python :: 3
|
|
11
|
+
Classifier: Programming Language :: Python :: 3.8
|
|
12
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
16
|
+
Requires-Dist: python-dateutil (>=2.8.1,<3.0.0)
|
|
17
|
+
Description-Content-Type: text/markdown
|
|
18
|
+
|
|
19
|
+
# AWS Croniter
|
|
20
|
+
|
|
21
|
+
AWS Croniter is a Python library that provides utilities for working with AWS cron expressions. It allows you to easily parse, manipulate, and validate cron expressions used in AWS services like CloudWatch and EventBridge.
|
|
22
|
+
|
|
23
|
+
## Features
|
|
24
|
+
|
|
25
|
+
- Parse AWS cron expressions
|
|
26
|
+
- Validate cron expressions
|
|
27
|
+
- Generate next execution times
|
|
28
|
+
- Convert cron expressions to human-readable format
|
|
29
|
+
|
|
30
|
+
## Installation
|
|
31
|
+
|
|
32
|
+
You can install AWS Croniter using pip:
|
|
33
|
+
|
|
34
|
+
```sh
|
|
35
|
+
pip install aws-croniter
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
## Usage
|
|
39
|
+
|
|
40
|
+
Here is a basic example of how to use AWS Croniter:
|
|
41
|
+
|
|
42
|
+
```python
|
|
43
|
+
from aws_croniter import Croniter
|
|
44
|
+
|
|
45
|
+
# Create a Croniter object with an AWS cron expression
|
|
46
|
+
cron = Croniter('cron(0 12 * * ? *)')
|
|
47
|
+
|
|
48
|
+
# Validate the cron expression
|
|
49
|
+
if cron.is_valid():
|
|
50
|
+
print("The cron expression is valid.")
|
|
51
|
+
|
|
52
|
+
# Get the next execution time
|
|
53
|
+
next_time = cron.get_next()
|
|
54
|
+
print(f"The next execution time is: {next_time}")
|
|
55
|
+
|
|
56
|
+
# Convert to human-readable format
|
|
57
|
+
human_readable = cron.to_human_readable()
|
|
58
|
+
print(f"Human-readable format: {human_readable}")
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
## Contributing
|
|
62
|
+
|
|
63
|
+
Contributions are welcome! Please read the [contributing guidelines](CONTRIBUTING.md) first.
|
|
64
|
+
|
|
65
|
+
## License
|
|
66
|
+
|
|
67
|
+
This project is licensed under the MIT License. See the [LICENSE](LICENSE) file for details.
|
|
68
|
+
|
|
69
|
+
## Contact
|
|
70
|
+
|
|
71
|
+
For any questions or suggestions, please open an issue or contact the maintainer at [email@example.com](mailto:email@example.com).
|
|
72
|
+
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
# AWS Croniter
|
|
2
|
+
|
|
3
|
+
AWS Croniter is a Python library that provides utilities for working with AWS cron expressions. It allows you to easily parse, manipulate, and validate cron expressions used in AWS services like CloudWatch and EventBridge.
|
|
4
|
+
|
|
5
|
+
## Features
|
|
6
|
+
|
|
7
|
+
- Parse AWS cron expressions
|
|
8
|
+
- Validate cron expressions
|
|
9
|
+
- Generate next execution times
|
|
10
|
+
- Convert cron expressions to human-readable format
|
|
11
|
+
|
|
12
|
+
## Installation
|
|
13
|
+
|
|
14
|
+
You can install AWS Croniter using pip:
|
|
15
|
+
|
|
16
|
+
```sh
|
|
17
|
+
pip install aws-croniter
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
## Usage
|
|
21
|
+
|
|
22
|
+
Here is a basic example of how to use AWS Croniter:
|
|
23
|
+
|
|
24
|
+
```python
|
|
25
|
+
from aws_croniter import Croniter
|
|
26
|
+
|
|
27
|
+
# Create a Croniter object with an AWS cron expression
|
|
28
|
+
cron = Croniter('cron(0 12 * * ? *)')
|
|
29
|
+
|
|
30
|
+
# Validate the cron expression
|
|
31
|
+
if cron.is_valid():
|
|
32
|
+
print("The cron expression is valid.")
|
|
33
|
+
|
|
34
|
+
# Get the next execution time
|
|
35
|
+
next_time = cron.get_next()
|
|
36
|
+
print(f"The next execution time is: {next_time}")
|
|
37
|
+
|
|
38
|
+
# Convert to human-readable format
|
|
39
|
+
human_readable = cron.to_human_readable()
|
|
40
|
+
print(f"Human-readable format: {human_readable}")
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
## Contributing
|
|
44
|
+
|
|
45
|
+
Contributions are welcome! Please read the [contributing guidelines](CONTRIBUTING.md) first.
|
|
46
|
+
|
|
47
|
+
## License
|
|
48
|
+
|
|
49
|
+
This project is licensed under the MIT License. See the [LICENSE](LICENSE) file for details.
|
|
50
|
+
|
|
51
|
+
## Contact
|
|
52
|
+
|
|
53
|
+
For any questions or suggestions, please open an issue or contact the maintainer at [email@example.com](mailto:email@example.com).
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
[tool.poetry]
|
|
2
|
+
name = "aws-croniter"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
description = "A Python utility for AWS cron expressions. Validate and parse AWS EventBridge cron expressions seamlessly."
|
|
5
|
+
authors = ["Siddarth Patil <siddarth3639@gmail.com>"]
|
|
6
|
+
license = "MIT"
|
|
7
|
+
readme = "README.md"
|
|
8
|
+
|
|
9
|
+
packages = [{ include = "aws_croniter", from = "src" }]
|
|
10
|
+
|
|
11
|
+
[tool.poetry.dependencies]
|
|
12
|
+
python = "^3.8"
|
|
13
|
+
python-dateutil = "^2.8.1"
|
|
14
|
+
|
|
15
|
+
[tool.poetry.group.test.dependencies]
|
|
16
|
+
coverage = "^7.6"
|
|
17
|
+
pytest = "^8.0.0"
|
|
18
|
+
pytest-cov = "^5.0"
|
|
19
|
+
tox = "^4.23.2"
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
[tool.pytest.ini_options]
|
|
23
|
+
markers = [
|
|
24
|
+
"package",
|
|
25
|
+
]
|
|
26
|
+
testpaths = "tests"
|
|
27
|
+
addopts = "--durations=3 --cov --cov-report=html --cov-report=xml"
|
|
28
|
+
|
|
29
|
+
[tool.coverage.run]
|
|
30
|
+
source = ["aws_croniter"]
|
|
31
|
+
|
|
32
|
+
[tool.coverage.report]
|
|
33
|
+
fail_under = 80
|
|
34
|
+
|
|
35
|
+
[tool.ruff]
|
|
36
|
+
# Setting the maximum line length
|
|
37
|
+
line-length = 120
|
|
38
|
+
include = ["src"]
|
|
39
|
+
|
|
40
|
+
[tool.ruff.lint]
|
|
41
|
+
# Extending the rules to apply
|
|
42
|
+
extend-select = [
|
|
43
|
+
"S", # flake8-bandit
|
|
44
|
+
"E501", # Line too long
|
|
45
|
+
"W292", # No newline at end of file
|
|
46
|
+
"W293", # Blank line contains whitespace
|
|
47
|
+
]
|
|
48
|
+
|
|
49
|
+
[tool.ruff.lint.isort]
|
|
50
|
+
force-single-line = true
|
|
51
|
+
|
|
52
|
+
[tool.ruff.lint.extend-per-file-ignores]
|
|
53
|
+
"tests/*" = [
|
|
54
|
+
"S101", # asserts allowed in tests
|
|
55
|
+
]
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
[build-system]
|
|
59
|
+
requires = ["poetry-core"]
|
|
60
|
+
build-backend = "poetry.core.masonry.api"
|
|
File without changes
|
|
@@ -0,0 +1,264 @@
|
|
|
1
|
+
import datetime
|
|
2
|
+
import re
|
|
3
|
+
|
|
4
|
+
from aws_croniter.exceptions import AWSCronExpressionDayOfMonthError
|
|
5
|
+
from aws_croniter.exceptions import AWSCronExpressionDayOfWeekError
|
|
6
|
+
from aws_croniter.exceptions import AWSCronExpressionError
|
|
7
|
+
from aws_croniter.exceptions import AWSCronExpressionHourError
|
|
8
|
+
from aws_croniter.exceptions import AWSCronExpressionMinuteError
|
|
9
|
+
from aws_croniter.exceptions import AWSCronExpressionMonthError
|
|
10
|
+
from aws_croniter.exceptions import AWSCronExpressionYearError
|
|
11
|
+
from aws_croniter.occurrence import Occurrence
|
|
12
|
+
from aws_croniter.utils import RegexUtils
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class AWSCron:
|
|
16
|
+
MONTH_REPLACES = [
|
|
17
|
+
["JAN", "1"],
|
|
18
|
+
["FEB", "2"],
|
|
19
|
+
["MAR", "3"],
|
|
20
|
+
["APR", "4"],
|
|
21
|
+
["MAY", "5"],
|
|
22
|
+
["JUN", "6"],
|
|
23
|
+
["JUL", "7"],
|
|
24
|
+
["AUG", "8"],
|
|
25
|
+
["SEP", "9"],
|
|
26
|
+
["OCT", "10"],
|
|
27
|
+
["NOV", "11"],
|
|
28
|
+
["DEC", "12"],
|
|
29
|
+
]
|
|
30
|
+
|
|
31
|
+
DAY_WEEK_REPLACES = [
|
|
32
|
+
["SUN", "1"],
|
|
33
|
+
["MON", "2"],
|
|
34
|
+
["TUE", "3"],
|
|
35
|
+
["WED", "4"],
|
|
36
|
+
["THU", "5"],
|
|
37
|
+
["FRI", "6"],
|
|
38
|
+
["SAT", "7"],
|
|
39
|
+
]
|
|
40
|
+
|
|
41
|
+
def __init__(self, cron):
|
|
42
|
+
self.cron = cron
|
|
43
|
+
self.minutes = None
|
|
44
|
+
self.hours = None
|
|
45
|
+
self.days_of_month = None
|
|
46
|
+
self.months = None
|
|
47
|
+
self.days_of_week = None
|
|
48
|
+
self.years = None
|
|
49
|
+
self.rules = cron.split(" ")
|
|
50
|
+
self.__validate()
|
|
51
|
+
|
|
52
|
+
def __validate(self):
|
|
53
|
+
"""
|
|
54
|
+
Validates these AWS EventBridge cron expressions, which are similar to, but not compatible with standard
|
|
55
|
+
unix cron expressions:
|
|
56
|
+
https://docs.aws.amazon.com/eventbridge/latest/userguide/eb-create-rule-schedule.html#eb-cron-expressions
|
|
57
|
+
|
|
58
|
+
| Field | Values | Wildcards |
|
|
59
|
+
| :----------: | :-------------: | :-----------: |
|
|
60
|
+
| Minute | 0-59 | , - * / |
|
|
61
|
+
| Hour | 0-23 | , - * / |
|
|
62
|
+
| Day-of-month | 1-31 | , - * ? / L W |
|
|
63
|
+
| Month | 1-12 or JAN-DEC | , - * / |
|
|
64
|
+
| Day-of-week | 1-7 or SUN-SAT | , - * ? L # |
|
|
65
|
+
| Year | 1970-2199 | , - * / |
|
|
66
|
+
"""
|
|
67
|
+
value_count = len(self.cron.split(" "))
|
|
68
|
+
if value_count != 6:
|
|
69
|
+
raise AWSCronExpressionError(
|
|
70
|
+
f"Incorrect number of values in '{self.cron}'. 6 required, {value_count} provided."
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
minute, hour, day_of_month, month, day_of_week, year = self.cron.split(" ")
|
|
74
|
+
|
|
75
|
+
if not ((day_of_month == "?" and day_of_week != "?") or (day_of_month != "?" and day_of_week == "?")):
|
|
76
|
+
raise AWSCronExpressionError(
|
|
77
|
+
f"Invalid combination of day-of-month '{day_of_month}' and day-of-week '{day_of_week}'."
|
|
78
|
+
"One must be a question mark (?)"
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
if not re.fullmatch(RegexUtils.minute_regex(), minute):
|
|
82
|
+
raise AWSCronExpressionMinuteError(f"Invalid minute value '{minute}'.")
|
|
83
|
+
if not re.fullmatch(RegexUtils.hour_regex(), hour):
|
|
84
|
+
raise AWSCronExpressionHourError(f"Invalid hour value '{hour}'.")
|
|
85
|
+
if not re.fullmatch(RegexUtils.day_of_month_regex(), day_of_month):
|
|
86
|
+
raise AWSCronExpressionDayOfMonthError(f"Invalid day-of-month value '{day_of_month}'.")
|
|
87
|
+
if not re.fullmatch(RegexUtils.month_regex(), month):
|
|
88
|
+
raise AWSCronExpressionMonthError(f"Invalid month value '{month}'.")
|
|
89
|
+
if not re.fullmatch(RegexUtils.day_of_week_regex(), day_of_week):
|
|
90
|
+
raise AWSCronExpressionDayOfWeekError(f"Invalid day-of-week value '{day_of_week}'.")
|
|
91
|
+
if not re.fullmatch(RegexUtils.year_regex(), year):
|
|
92
|
+
raise AWSCronExpressionYearError(f"Invalid year value '{year}'.")
|
|
93
|
+
|
|
94
|
+
# If validation passes, then parse the cron expression
|
|
95
|
+
self.__parse()
|
|
96
|
+
|
|
97
|
+
def occurrence(self, utc_datetime):
|
|
98
|
+
if utc_datetime.tzinfo is None or utc_datetime.tzinfo != datetime.timezone.utc:
|
|
99
|
+
raise Exception("Occurrence utc_datetime must have tzinfo == datetime.timezone.utc")
|
|
100
|
+
return Occurrence(self, utc_datetime)
|
|
101
|
+
|
|
102
|
+
def __str__(self):
|
|
103
|
+
return f"cron({self.cron})"
|
|
104
|
+
|
|
105
|
+
def __replace(self, s, rules):
|
|
106
|
+
rs = str(s).upper()
|
|
107
|
+
for rule in rules:
|
|
108
|
+
rs = rs.replace(rule[0], rule[1])
|
|
109
|
+
return rs
|
|
110
|
+
|
|
111
|
+
def __parse(self):
|
|
112
|
+
self.minutes = self.__parse_one_rule(self.rules[0], 0, 59)
|
|
113
|
+
self.hours = self.__parse_one_rule(self.rules[1], 0, 23)
|
|
114
|
+
self.days_of_month = self.__parse_one_rule(self.rules[2], 1, 31)
|
|
115
|
+
self.months = self.__parse_one_rule(self.__replace(self.rules[3], AWSCron.MONTH_REPLACES), 1, 12)
|
|
116
|
+
self.days_of_week = self.__parse_one_rule(self.__replace(self.rules[4], AWSCron.DAY_WEEK_REPLACES), 1, 7)
|
|
117
|
+
self.years = self.__parse_one_rule(self.rules[5], 1970, 2199)
|
|
118
|
+
|
|
119
|
+
@staticmethod
|
|
120
|
+
def __parse_one_rule(rule, min_value, max_value):
|
|
121
|
+
if rule == "?":
|
|
122
|
+
return []
|
|
123
|
+
if rule == "L":
|
|
124
|
+
return ["L", 0]
|
|
125
|
+
if rule.startswith("L-"):
|
|
126
|
+
return ["L", int(rule[2:])]
|
|
127
|
+
if rule.endswith("L"):
|
|
128
|
+
return ["L", int(rule[0:-1])]
|
|
129
|
+
if rule.endswith("W"):
|
|
130
|
+
return ["W", int(rule[0:-1])]
|
|
131
|
+
if "#" in rule:
|
|
132
|
+
return ["#", int(rule.split("#")[0]), int(rule.split("#")[1])]
|
|
133
|
+
|
|
134
|
+
new_rule = None
|
|
135
|
+
if rule == "*":
|
|
136
|
+
new_rule = str(min_value) + "-" + str(max_value)
|
|
137
|
+
elif "/" in rule:
|
|
138
|
+
parts = rule.split("/")
|
|
139
|
+
start = None
|
|
140
|
+
end = None
|
|
141
|
+
if parts[0] == "*":
|
|
142
|
+
start = min_value
|
|
143
|
+
end = max_value
|
|
144
|
+
elif "-" in parts[0]:
|
|
145
|
+
splits = parts[0].split("-")
|
|
146
|
+
start = int(splits[0])
|
|
147
|
+
end = int(splits[1])
|
|
148
|
+
else:
|
|
149
|
+
start = int(parts[0])
|
|
150
|
+
end = max_value
|
|
151
|
+
increment = int(parts[1])
|
|
152
|
+
new_rule = ""
|
|
153
|
+
while start <= end:
|
|
154
|
+
new_rule += "," + str(start)
|
|
155
|
+
start += increment
|
|
156
|
+
new_rule = new_rule[1:]
|
|
157
|
+
else:
|
|
158
|
+
new_rule = rule
|
|
159
|
+
allows = []
|
|
160
|
+
for s in new_rule.split(","):
|
|
161
|
+
if "-" in s:
|
|
162
|
+
parts = s.split("-")
|
|
163
|
+
start = int(parts[0])
|
|
164
|
+
end = int(parts[1])
|
|
165
|
+
for i in range(start, end + 1, 1):
|
|
166
|
+
allows.append(i)
|
|
167
|
+
else:
|
|
168
|
+
allows.append(int(s))
|
|
169
|
+
allows.sort()
|
|
170
|
+
return allows
|
|
171
|
+
|
|
172
|
+
@staticmethod
|
|
173
|
+
def get_next_n_schedule(n, from_date, cron):
|
|
174
|
+
"""
|
|
175
|
+
Returns a list with the n next datetime(s) that match the aws cron expression from the provided start date.
|
|
176
|
+
|
|
177
|
+
:param n: Int of the n next datetime(s)
|
|
178
|
+
:param from_date: datetime with the start date
|
|
179
|
+
:param cron: str of aws cron to be parsed
|
|
180
|
+
:return: list of datetime objects
|
|
181
|
+
"""
|
|
182
|
+
schedule_list = list()
|
|
183
|
+
if not isinstance(from_date, datetime.datetime):
|
|
184
|
+
raise ValueError(
|
|
185
|
+
"Invalid from_date. Must be of type datetime.datetime" " and have tzinfo = datetime.timezone.utc"
|
|
186
|
+
)
|
|
187
|
+
else:
|
|
188
|
+
cron_iterator = AWSCron(cron)
|
|
189
|
+
for i in range(n):
|
|
190
|
+
from_date = cron_iterator.occurrence(from_date).next()
|
|
191
|
+
schedule_list.append(from_date)
|
|
192
|
+
|
|
193
|
+
return schedule_list
|
|
194
|
+
|
|
195
|
+
@staticmethod
|
|
196
|
+
def get_prev_n_schedule(n, from_date, cron):
|
|
197
|
+
"""
|
|
198
|
+
Returns a list with the n prev datetime(s) that match the aws cron expression
|
|
199
|
+
from the provided start date.
|
|
200
|
+
|
|
201
|
+
:param n: Int of the n next datetime(s)
|
|
202
|
+
:param from_date: datetime with the start date
|
|
203
|
+
:param cron: str of aws cron to be parsed
|
|
204
|
+
:return: list of datetime objects
|
|
205
|
+
"""
|
|
206
|
+
schedule_list = list()
|
|
207
|
+
if not isinstance(from_date, datetime.datetime):
|
|
208
|
+
raise ValueError(
|
|
209
|
+
"Invalid from_date. Must be of type datetime.datetime" " and have tzinfo = datetime.timezone.utc"
|
|
210
|
+
)
|
|
211
|
+
else:
|
|
212
|
+
cron_iterator = AWSCron(cron)
|
|
213
|
+
for i in range(n):
|
|
214
|
+
from_date = cron_iterator.occurrence(from_date).prev()
|
|
215
|
+
schedule_list.append(from_date)
|
|
216
|
+
|
|
217
|
+
return schedule_list
|
|
218
|
+
|
|
219
|
+
@staticmethod
|
|
220
|
+
def get_all_schedule_bw_dates(from_date, to_date, cron, exclude_ends=False):
|
|
221
|
+
"""
|
|
222
|
+
Get all datetimes from from_date to to_date matching the given cron expression.
|
|
223
|
+
If the cron expression matches either 'from_date' and/or 'to_date',
|
|
224
|
+
those times will be returned as well unless 'exclude_ends=True' is passed.
|
|
225
|
+
|
|
226
|
+
:param from_date: datetime object from where the schedule will start with tzinfo in utc.
|
|
227
|
+
:param to_date: datetime object to where the schedule will end with tzinfo in utc.
|
|
228
|
+
:param cron: str of aws cron to be parsed
|
|
229
|
+
:param exclude_ends: bool defaulted to False, to not exclude the end date
|
|
230
|
+
:return: list of datetime objects
|
|
231
|
+
"""
|
|
232
|
+
|
|
233
|
+
if type(from_date) != type(to_date) and not (
|
|
234
|
+
isinstance(from_date, type(to_date)) or isinstance(to_date, type(from_date))
|
|
235
|
+
):
|
|
236
|
+
raise ValueError(
|
|
237
|
+
"The from_date and to_date must be same type." " {0} != {1}".format(type(from_date), type(to_date))
|
|
238
|
+
)
|
|
239
|
+
|
|
240
|
+
elif not isinstance(from_date, datetime.datetime) or (from_date.tzinfo != datetime.timezone.utc):
|
|
241
|
+
raise ValueError(
|
|
242
|
+
"Invalid from_date and to_date. Must be of type datetime.datetime"
|
|
243
|
+
" and have tzinfo = datetime.timezone.utc"
|
|
244
|
+
)
|
|
245
|
+
else:
|
|
246
|
+
schedule_list = []
|
|
247
|
+
cron_iterator = AWSCron(cron)
|
|
248
|
+
start = from_date.replace(second=0, microsecond=0) - datetime.timedelta(seconds=1)
|
|
249
|
+
stop = to_date.replace(second=0, microsecond=0)
|
|
250
|
+
|
|
251
|
+
while start is not None and start <= stop:
|
|
252
|
+
start = cron_iterator.occurrence(start).next()
|
|
253
|
+
if start is None or start > stop:
|
|
254
|
+
break
|
|
255
|
+
schedule_list.append(start)
|
|
256
|
+
|
|
257
|
+
# If exclude_ends=True ,
|
|
258
|
+
# remove first & last element from the list if they match from_date & to_date
|
|
259
|
+
if exclude_ends:
|
|
260
|
+
if schedule_list[0] == from_date.replace(second=0, microsecond=0):
|
|
261
|
+
schedule_list.pop(0)
|
|
262
|
+
if schedule_list[-1] == to_date.replace(second=0, microsecond=0):
|
|
263
|
+
schedule_list.pop()
|
|
264
|
+
return schedule_list
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
class AWSCronExpressionError(ValueError):
|
|
2
|
+
"""Base exception for errors in AWS cron expressions."""
|
|
3
|
+
|
|
4
|
+
pass
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class AWSCronExpressionMinuteError(AWSCronExpressionError):
|
|
8
|
+
"""Exception raised for invalid minute values in an AWS cron expression."""
|
|
9
|
+
|
|
10
|
+
pass
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class AWSCronExpressionHourError(AWSCronExpressionError):
|
|
14
|
+
"""Exception raised for invalid hour values in an AWS cron expression."""
|
|
15
|
+
|
|
16
|
+
pass
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class AWSCronExpressionMonthError(AWSCronExpressionError):
|
|
20
|
+
"""Exception raised for invalid month values in an AWS cron expression."""
|
|
21
|
+
|
|
22
|
+
pass
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class AWSCronExpressionYearError(AWSCronExpressionError):
|
|
26
|
+
"""Exception raised for invalid year values in an AWS cron expression."""
|
|
27
|
+
|
|
28
|
+
pass
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class AWSCronExpressionDayOfMonthError(AWSCronExpressionError):
|
|
32
|
+
"""Exception raised for invalid day-of-month values in an AWS cron expression."""
|
|
33
|
+
|
|
34
|
+
pass
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class AWSCronExpressionDayOfWeekError(AWSCronExpressionError):
|
|
38
|
+
"""Exception raised for invalid day-of-week values in an AWS cron expression."""
|
|
39
|
+
|
|
40
|
+
pass
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
import datetime
|
|
2
|
+
import math
|
|
3
|
+
|
|
4
|
+
from dateutil.relativedelta import relativedelta
|
|
5
|
+
|
|
6
|
+
from aws_croniter.utils import DateUtils
|
|
7
|
+
from aws_croniter.utils import SequenceUtils
|
|
8
|
+
from aws_croniter.utils import TimeUtils
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class Occurrence:
|
|
12
|
+
def __init__(self, AWSCron, utc_datetime):
|
|
13
|
+
if utc_datetime.tzinfo is None or utc_datetime.tzinfo != datetime.timezone.utc:
|
|
14
|
+
raise Exception("Occurrence utc_datetime must have tzinfo == datetime.timezone.utc")
|
|
15
|
+
self.utc_datetime = utc_datetime
|
|
16
|
+
self.cron = AWSCron
|
|
17
|
+
self.iter = 0
|
|
18
|
+
|
|
19
|
+
def __find_once(self, parsed, datetime_from):
|
|
20
|
+
if self.iter > 10:
|
|
21
|
+
raise Exception(f"AwsCronParser : this shouldn't happen, but iter {self.iter} > 10 ")
|
|
22
|
+
self.iter += 1
|
|
23
|
+
current_year = datetime_from.year
|
|
24
|
+
current_month = datetime_from.month
|
|
25
|
+
current_day_of_month = datetime_from.day
|
|
26
|
+
current_hour = datetime_from.hour
|
|
27
|
+
current_minute = datetime_from.minute
|
|
28
|
+
|
|
29
|
+
year = SequenceUtils.array_find_first(parsed.years, lambda c: c >= current_year)
|
|
30
|
+
if year is None:
|
|
31
|
+
return None
|
|
32
|
+
|
|
33
|
+
month = SequenceUtils.array_find_first(
|
|
34
|
+
parsed.months, lambda c: c >= (current_month if year == current_year else 1)
|
|
35
|
+
)
|
|
36
|
+
if not month:
|
|
37
|
+
return self.__find_once(parsed, datetime.datetime(year + 1, 1, 1, tzinfo=datetime.timezone.utc))
|
|
38
|
+
|
|
39
|
+
is_same_month = True if year == current_year and month == current_month else False
|
|
40
|
+
p_days_of_month = parsed.days_of_month
|
|
41
|
+
is_w_in_current_month = None
|
|
42
|
+
|
|
43
|
+
if len(p_days_of_month) == 0:
|
|
44
|
+
p_days_of_month = DateUtils.get_days_of_month_from_days_of_week(year, month, parsed.days_of_week)
|
|
45
|
+
elif p_days_of_month[0] == "L":
|
|
46
|
+
p_days_of_month = DateUtils.get_days_of_month_for_L(year, month, int(p_days_of_month[1]))
|
|
47
|
+
elif p_days_of_month[0] == "W":
|
|
48
|
+
if DateUtils.is_day_in_month(year, month, int(p_days_of_month[1])):
|
|
49
|
+
p_days_of_month = DateUtils.get_days_of_month_for_W(year, month, int(p_days_of_month[1]))
|
|
50
|
+
is_w_in_current_month = True
|
|
51
|
+
else:
|
|
52
|
+
is_w_in_current_month = False
|
|
53
|
+
if is_w_in_current_month is not None and not is_w_in_current_month:
|
|
54
|
+
day_of_month = False
|
|
55
|
+
else:
|
|
56
|
+
day_of_month = SequenceUtils.array_find_first(
|
|
57
|
+
p_days_of_month, lambda c: c >= (current_day_of_month if is_same_month else 1)
|
|
58
|
+
)
|
|
59
|
+
if not day_of_month:
|
|
60
|
+
dt = datetime.datetime(year, month, 1, tzinfo=datetime.timezone.utc) + relativedelta(months=+1)
|
|
61
|
+
return self.__find_once(parsed, dt)
|
|
62
|
+
|
|
63
|
+
is_same_date = is_same_month and day_of_month == current_day_of_month
|
|
64
|
+
|
|
65
|
+
hour = SequenceUtils.array_find_first(parsed.hours, lambda c: c >= (current_hour if is_same_date else 0))
|
|
66
|
+
if hour is None:
|
|
67
|
+
dt = datetime.datetime(year, month, day_of_month, tzinfo=datetime.timezone.utc) + relativedelta(days=+1)
|
|
68
|
+
return self.__find_once(parsed, dt)
|
|
69
|
+
|
|
70
|
+
minute = SequenceUtils.array_find_first(
|
|
71
|
+
parsed.minutes, lambda c: c >= (current_minute if is_same_date and hour == current_hour else 0)
|
|
72
|
+
)
|
|
73
|
+
if minute is None:
|
|
74
|
+
dt = datetime.datetime(year, month, day_of_month, hour, tzinfo=datetime.timezone.utc) + relativedelta(
|
|
75
|
+
hours=+1
|
|
76
|
+
)
|
|
77
|
+
return self.__find_once(parsed, dt)
|
|
78
|
+
|
|
79
|
+
return datetime.datetime(year, month, day_of_month, hour, minute, tzinfo=datetime.timezone.utc)
|
|
80
|
+
|
|
81
|
+
def __find_prev_once(self, parsed, datetime_from: datetime):
|
|
82
|
+
if self.iter > 10:
|
|
83
|
+
raise Exception("AwsCronParser : this shouldn't happen, but iter > 10")
|
|
84
|
+
self.iter += 1
|
|
85
|
+
current_year = datetime_from.year
|
|
86
|
+
current_month = datetime_from.month
|
|
87
|
+
current_day_of_month = datetime_from.day
|
|
88
|
+
current_hour = datetime_from.hour
|
|
89
|
+
current_minute = datetime_from.minute
|
|
90
|
+
|
|
91
|
+
year = SequenceUtils.array_find_last(parsed.years, lambda c: c <= current_year)
|
|
92
|
+
if year is None:
|
|
93
|
+
return None
|
|
94
|
+
|
|
95
|
+
month = SequenceUtils.array_find_last(
|
|
96
|
+
parsed.months, lambda c: c <= (current_month if year == current_year else 12)
|
|
97
|
+
)
|
|
98
|
+
if not month:
|
|
99
|
+
dt = datetime.datetime(year, 1, 1, tzinfo=datetime.timezone.utc) + relativedelta(seconds=-1)
|
|
100
|
+
return self.__find_prev_once(parsed, dt)
|
|
101
|
+
|
|
102
|
+
is_same_month = True if year == current_year and month == current_month else False
|
|
103
|
+
p_days_of_month = parsed.days_of_month
|
|
104
|
+
is_w_in_current_month = None
|
|
105
|
+
|
|
106
|
+
if len(p_days_of_month) == 0:
|
|
107
|
+
p_days_of_month = DateUtils.get_days_of_month_from_days_of_week(year, month, parsed.days_of_week)
|
|
108
|
+
elif p_days_of_month[0] == "L":
|
|
109
|
+
p_days_of_month = DateUtils.get_days_of_month_for_L(year, month, int(p_days_of_month[1]))
|
|
110
|
+
elif p_days_of_month[0] == "W":
|
|
111
|
+
if DateUtils.is_day_in_month(year, month, int(p_days_of_month[1])):
|
|
112
|
+
p_days_of_month = DateUtils.get_days_of_month_for_W(year, month, int(p_days_of_month[1]))
|
|
113
|
+
is_w_in_current_month = True
|
|
114
|
+
else:
|
|
115
|
+
is_w_in_current_month = False
|
|
116
|
+
if is_w_in_current_month is not None and not is_w_in_current_month:
|
|
117
|
+
day_of_month = False
|
|
118
|
+
else:
|
|
119
|
+
day_of_month = SequenceUtils.array_find_last(
|
|
120
|
+
p_days_of_month, lambda c: c <= (current_day_of_month if is_same_month else 31)
|
|
121
|
+
)
|
|
122
|
+
|
|
123
|
+
if not day_of_month:
|
|
124
|
+
dt = datetime.datetime(year, month, 1, tzinfo=datetime.timezone.utc) + relativedelta(seconds=-1)
|
|
125
|
+
return self.__find_prev_once(parsed, dt)
|
|
126
|
+
|
|
127
|
+
is_same_date = is_same_month and day_of_month == current_day_of_month
|
|
128
|
+
|
|
129
|
+
hour = SequenceUtils.array_find_last(parsed.hours, lambda c: c <= (current_hour if is_same_date else 23))
|
|
130
|
+
if hour is None:
|
|
131
|
+
dt = datetime.datetime(year, month, day_of_month, tzinfo=datetime.timezone.utc) + relativedelta(seconds=-1)
|
|
132
|
+
return self.__find_prev_once(parsed, dt)
|
|
133
|
+
|
|
134
|
+
minute = SequenceUtils.array_find_last(
|
|
135
|
+
parsed.minutes, lambda c: c <= (current_minute if is_same_date and hour == current_hour else 59)
|
|
136
|
+
)
|
|
137
|
+
if minute is None:
|
|
138
|
+
dt = datetime.datetime(year, month, day_of_month, hour, tzinfo=datetime.timezone.utc) + relativedelta(
|
|
139
|
+
seconds=-1
|
|
140
|
+
)
|
|
141
|
+
return self.__find_prev_once(parsed, dt)
|
|
142
|
+
|
|
143
|
+
return datetime.datetime(year, month, day_of_month, hour, minute, tzinfo=datetime.timezone.utc)
|
|
144
|
+
|
|
145
|
+
def next(self, datetime_inclusive=False):
|
|
146
|
+
"""
|
|
147
|
+
Generate the next occurrence after the current time.
|
|
148
|
+
|
|
149
|
+
:param datetime_inclusive: If True, include the current time if it matches a valid execution.
|
|
150
|
+
:return: The next occurrence as a datetime object.
|
|
151
|
+
"""
|
|
152
|
+
self.iter = 0
|
|
153
|
+
from_epoch = (math.floor(TimeUtils.datetime_to_millisec(self.utc_datetime) / 60000.0) + 1) * 60000
|
|
154
|
+
if datetime_inclusive:
|
|
155
|
+
# Do not add extra minute, include current time
|
|
156
|
+
from_epoch = math.floor(TimeUtils.datetime_to_millisec(self.utc_datetime) / 60000.0) * 60000
|
|
157
|
+
dt = datetime.datetime.fromtimestamp(from_epoch / 1000.0, tz=datetime.timezone.utc)
|
|
158
|
+
return self.__find_once(self.cron, dt)
|
|
159
|
+
|
|
160
|
+
def prev(self, datetime_inclusive=False):
|
|
161
|
+
"""
|
|
162
|
+
Generate the prev before the occurrence date value
|
|
163
|
+
|
|
164
|
+
:param datetime_inclusive: If True, include the current time if it matches a valid execution.
|
|
165
|
+
:return: The next occurrence as a datetime object.
|
|
166
|
+
"""
|
|
167
|
+
self.iter = 0
|
|
168
|
+
from_epoch = (math.floor(TimeUtils.datetime_to_millisec(self.utc_datetime) / 60000.0) - 1) * 60000
|
|
169
|
+
if datetime_inclusive:
|
|
170
|
+
# Do not subtract extra minute, include current time
|
|
171
|
+
from_epoch = math.floor(TimeUtils.datetime_to_millisec(self.utc_datetime) / 60000.0) * 60000
|
|
172
|
+
dt = datetime.datetime.fromtimestamp(from_epoch / 1000.0, tz=datetime.timezone.utc)
|
|
173
|
+
return self.__find_prev_once(self.cron, dt)
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
import calendar
|
|
2
|
+
import datetime
|
|
3
|
+
|
|
4
|
+
from dateutil.relativedelta import relativedelta
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class RegexUtils:
|
|
8
|
+
MINUTE_VALUES = r"(0?[0-9]|[1-5][0-9])" # [0]0-59
|
|
9
|
+
HOUR_VALUES = r"(0?[0-9]|1[0-9]|2[0-3])" # [0]0-23
|
|
10
|
+
MONTH_OF_DAY_VALUES = r"(0?[1-9]|[1-2][0-9]|3[0-1])" # [0]1-31
|
|
11
|
+
MONTH_VALUES = r"(?i:0?[1-9]|1[0-2]|JAN|FEB|MAR|APR|MAY|JUN|JUL|AUG|SEP|OCT|NOV|DEC)" # [0]1-12 or JAN-DEC
|
|
12
|
+
DAY_OF_WEEK_VALUES = r"(?i:[1-7]|SUN|MON|TUE|WED|THU|FRI|SAT)" # 1-7 or SAT-SUN
|
|
13
|
+
DAY_OF_WEEK_HASH = rf"({DAY_OF_WEEK_VALUES}#[1-5])" # Day of the week in the Nth week of the month
|
|
14
|
+
YEAR_VALUES = r"((19[7-9][0-9])|(2[0-1][0-9][0-9]))" # 1970-2199
|
|
15
|
+
|
|
16
|
+
@classmethod
|
|
17
|
+
def range_regex(cls, values: str) -> str:
|
|
18
|
+
return rf"({values}|(\*\-{values})|({values}\-{values})|({values}\-\*))" # v , *-v , v-v or v-*
|
|
19
|
+
|
|
20
|
+
@classmethod
|
|
21
|
+
def list_range_regex(cls, values: str) -> str:
|
|
22
|
+
range_ = cls.range_regex(values)
|
|
23
|
+
return rf"({range_}(\,{range_})*)" # One or more ranges separated by a comma
|
|
24
|
+
|
|
25
|
+
@classmethod
|
|
26
|
+
def slash_regex(cls, values: str) -> str:
|
|
27
|
+
range_ = cls.range_regex(values)
|
|
28
|
+
return rf"((\*|{range_}|{values})\/{values})"
|
|
29
|
+
# Slash can be preceded by *, range, or a valid value and must be followed by a natural
|
|
30
|
+
# number as the increment.
|
|
31
|
+
|
|
32
|
+
@classmethod
|
|
33
|
+
def list_slash_regex(cls, values: str) -> str:
|
|
34
|
+
slash = cls.slash_regex(values)
|
|
35
|
+
slash_or_range = rf"({slash}|{cls.range_regex(values)})"
|
|
36
|
+
return rf"({slash_or_range}(\,{slash_or_range})*)" # One or more separated by a comma
|
|
37
|
+
|
|
38
|
+
@classmethod
|
|
39
|
+
def common_regex(cls, values: str) -> str:
|
|
40
|
+
return rf"({cls.list_range_regex(values)}|\*|{cls.list_slash_regex(values)})" # values , - * /
|
|
41
|
+
|
|
42
|
+
@classmethod
|
|
43
|
+
def minute_regex(cls) -> str:
|
|
44
|
+
return rf"^({cls.common_regex(cls.MINUTE_VALUES)})$" # values , - * /
|
|
45
|
+
|
|
46
|
+
@classmethod
|
|
47
|
+
def hour_regex(cls) -> str:
|
|
48
|
+
return rf"^({cls.common_regex(cls.HOUR_VALUES)})$" # values , - * /
|
|
49
|
+
|
|
50
|
+
@classmethod
|
|
51
|
+
def day_of_month_regex(cls) -> str:
|
|
52
|
+
return (
|
|
53
|
+
rf"^({cls.common_regex(cls.MONTH_OF_DAY_VALUES)}|\?|L|L-[1-9]|[1-2][0-9]|3[0-1]|LW|{cls.MONTH_OF_DAY_VALUES}W)$"
|
|
54
|
+
# values , - * / ? L W
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
@classmethod
|
|
58
|
+
def month_regex(cls):
|
|
59
|
+
return rf"^({cls.common_regex(cls.MONTH_VALUES)})$" # values , - * /
|
|
60
|
+
|
|
61
|
+
@classmethod
|
|
62
|
+
def day_of_week_regex(cls):
|
|
63
|
+
range_list = cls.list_range_regex(cls.DAY_OF_WEEK_VALUES)
|
|
64
|
+
return rf"^({range_list}|\*|\?|{cls.DAY_OF_WEEK_VALUES}L|L|L-[1-7]|{cls.DAY_OF_WEEK_HASH})$"
|
|
65
|
+
# values , - * ? L #
|
|
66
|
+
|
|
67
|
+
@classmethod
|
|
68
|
+
def year_regex(cls):
|
|
69
|
+
return rf"^({cls.common_regex(cls.YEAR_VALUES)})$" # values , - * /
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
class DateUtils:
|
|
73
|
+
@staticmethod
|
|
74
|
+
def python_to_aws_day_of_week(python_day_of_week):
|
|
75
|
+
"""Convert Python day of week (Mon=0) to AWS day of week (Mon=2)."""
|
|
76
|
+
mapping = {0: 2, 1: 3, 2: 4, 3: 5, 4: 6, 5: 7, 6: 1}
|
|
77
|
+
return mapping[python_day_of_week]
|
|
78
|
+
|
|
79
|
+
@staticmethod
|
|
80
|
+
def get_days_of_month_from_days_of_week(year, month, days_of_week):
|
|
81
|
+
"""Get all days of the month that match the given days of the week."""
|
|
82
|
+
days_of_month = []
|
|
83
|
+
index = 0 # only for "#" use case
|
|
84
|
+
no_of_days_in_month = calendar.monthrange(year, month)[1]
|
|
85
|
+
for i in range(1, no_of_days_in_month + 1, 1):
|
|
86
|
+
this_date = datetime.datetime(year, month, i, tzinfo=datetime.timezone.utc)
|
|
87
|
+
if days_of_week[0] == "L":
|
|
88
|
+
if days_of_week[1] == DateUtils.python_to_aws_day_of_week(this_date.weekday()):
|
|
89
|
+
same_day_next_week = datetime.datetime.fromtimestamp(
|
|
90
|
+
int(this_date.timestamp()) + 7 * 24 * 3600, tz=datetime.timezone.utc
|
|
91
|
+
)
|
|
92
|
+
if same_day_next_week.month != this_date.month:
|
|
93
|
+
return [i]
|
|
94
|
+
elif days_of_week[0] == "#":
|
|
95
|
+
if days_of_week[1] == DateUtils.python_to_aws_day_of_week(this_date.weekday()):
|
|
96
|
+
index += 1
|
|
97
|
+
if days_of_week[2] == index:
|
|
98
|
+
return [i]
|
|
99
|
+
elif DateUtils.python_to_aws_day_of_week(this_date.weekday()) in days_of_week:
|
|
100
|
+
days_of_month.append(i)
|
|
101
|
+
return days_of_month
|
|
102
|
+
|
|
103
|
+
@staticmethod
|
|
104
|
+
def get_days_of_month_for_L(year, month, days_before):
|
|
105
|
+
"""Get the last day of the month adjusted by a specific number of days."""
|
|
106
|
+
for i in range(31, 28 - 1, -1):
|
|
107
|
+
this_date = datetime.datetime(year, month, 1, tzinfo=datetime.timezone.utc) + relativedelta(days=i - 1)
|
|
108
|
+
if this_date.month == month:
|
|
109
|
+
return [i - days_before]
|
|
110
|
+
|
|
111
|
+
@staticmethod
|
|
112
|
+
def get_days_of_month_for_W(year, month, day):
|
|
113
|
+
"""
|
|
114
|
+
Get the closest weekday for the specified day of the month.
|
|
115
|
+
Adjusts for weekends and ensures the date is within the month.
|
|
116
|
+
"""
|
|
117
|
+
offset = SequenceUtils.array_find_first([0, 1, -1, 2, -2], lambda c: DateUtils.is_weekday(year, month, day + c))
|
|
118
|
+
if offset is None:
|
|
119
|
+
return []
|
|
120
|
+
result = day + offset
|
|
121
|
+
return [result]
|
|
122
|
+
|
|
123
|
+
@staticmethod
|
|
124
|
+
def is_weekday(year, month, day):
|
|
125
|
+
"""Check if a specific day is a weekday (Mon-Fri)."""
|
|
126
|
+
if day < 1 or day > 31:
|
|
127
|
+
return False
|
|
128
|
+
this_date = datetime.datetime(year, month, 1, tzinfo=datetime.timezone.utc) + relativedelta(days=day - 1)
|
|
129
|
+
if this_date.month != month or this_date.year != year:
|
|
130
|
+
return False
|
|
131
|
+
return this_date.weekday() >= 0 and this_date.weekday() <= 4 # Mon=0, Fri=4
|
|
132
|
+
|
|
133
|
+
@staticmethod
|
|
134
|
+
def is_day_in_month(year, month, test_day):
|
|
135
|
+
"""Check if a specific day exists in a given month."""
|
|
136
|
+
try:
|
|
137
|
+
datetime.datetime(year, month, test_day, tzinfo=datetime.timezone.utc)
|
|
138
|
+
return True
|
|
139
|
+
except ValueError:
|
|
140
|
+
return False
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
class TimeUtils:
|
|
144
|
+
@staticmethod
|
|
145
|
+
def datetime_to_millisec(dt_obj):
|
|
146
|
+
"""Convert a datetime object to milliseconds since epoch."""
|
|
147
|
+
return round(dt_obj.timestamp() * 1000)
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
class SequenceUtils:
|
|
151
|
+
@staticmethod
|
|
152
|
+
def array_find_first(sequence, function):
|
|
153
|
+
"""Find the first element in a sequence that satisfies the given function."""
|
|
154
|
+
for item in sequence:
|
|
155
|
+
if function(item):
|
|
156
|
+
return item
|
|
157
|
+
return None
|
|
158
|
+
|
|
159
|
+
@staticmethod
|
|
160
|
+
def array_find_last(sequence, function):
|
|
161
|
+
"""Find the last element in a sequence that satisfies the given function."""
|
|
162
|
+
for item in reversed(sequence):
|
|
163
|
+
if function(item):
|
|
164
|
+
return item
|
|
165
|
+
return None
|