plain.jobs 0.43.2__py3-none-any.whl
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.
- plain/jobs/CHANGELOG.md +461 -0
- plain/jobs/README.md +300 -0
- plain/jobs/__init__.py +6 -0
- plain/jobs/admin.py +249 -0
- plain/jobs/chores.py +19 -0
- plain/jobs/cli.py +204 -0
- plain/jobs/config.py +19 -0
- plain/jobs/default_settings.py +6 -0
- plain/jobs/exceptions.py +34 -0
- plain/jobs/jobs.py +368 -0
- plain/jobs/locks.py +42 -0
- plain/jobs/middleware.py +42 -0
- plain/jobs/migrations/0001_initial.py +246 -0
- plain/jobs/migrations/0002_job_span_id_job_trace_id_jobrequest_span_id_and_more.py +61 -0
- plain/jobs/migrations/0003_rename_job_jobprocess_and_more.py +80 -0
- plain/jobs/migrations/0004_rename_tables_to_plainjobs.py +33 -0
- plain/jobs/migrations/0005_rename_constraints_and_indexes.py +174 -0
- plain/jobs/migrations/0006_alter_jobprocess_table_alter_jobrequest_table_and_more.py +24 -0
- plain/jobs/migrations/0007_remove_jobrequest_plainjobs_jobrequest_unique_job_class_key_and_more.py +144 -0
- plain/jobs/migrations/__init__.py +0 -0
- plain/jobs/models.py +567 -0
- plain/jobs/parameters.py +193 -0
- plain/jobs/registry.py +60 -0
- plain/jobs/scheduling.py +253 -0
- plain/jobs/templates/admin/plainqueue/jobresult_detail.html +8 -0
- plain/jobs/workers.py +355 -0
- plain_jobs-0.43.2.dist-info/METADATA +312 -0
- plain_jobs-0.43.2.dist-info/RECORD +30 -0
- plain_jobs-0.43.2.dist-info/WHEEL +4 -0
- plain_jobs-0.43.2.dist-info/licenses/LICENSE +28 -0
plain/jobs/parameters.py
ADDED
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import datetime
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
from plain.models import Model, models_registry
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class JobParameter:
|
|
10
|
+
"""Base class for job parameter serialization/deserialization."""
|
|
11
|
+
|
|
12
|
+
STR_PREFIX: str | None = None # Subclasses should define this
|
|
13
|
+
|
|
14
|
+
@classmethod
|
|
15
|
+
def serialize(cls, value: Any) -> str | None:
|
|
16
|
+
"""Return serialized string or None if can't handle this value."""
|
|
17
|
+
return None
|
|
18
|
+
|
|
19
|
+
@classmethod
|
|
20
|
+
def deserialize(cls, data: Any) -> Any:
|
|
21
|
+
"""Return deserialized value or None if can't handle this data."""
|
|
22
|
+
return None
|
|
23
|
+
|
|
24
|
+
@classmethod
|
|
25
|
+
def _extract_string_value(cls, data: Any) -> str | None:
|
|
26
|
+
"""Extract value from string with prefix, return None if invalid format."""
|
|
27
|
+
if not isinstance(data, str) or not cls.STR_PREFIX:
|
|
28
|
+
return None
|
|
29
|
+
if not data.startswith(cls.STR_PREFIX) or len(data) <= len(cls.STR_PREFIX):
|
|
30
|
+
return None
|
|
31
|
+
return data[len(cls.STR_PREFIX) :]
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class ModelParameter(JobParameter):
|
|
35
|
+
"""Handle Plain model instances using a new string format."""
|
|
36
|
+
|
|
37
|
+
STR_PREFIX = "__plain://model/"
|
|
38
|
+
|
|
39
|
+
@classmethod
|
|
40
|
+
def serialize(cls, value: Any) -> str | None:
|
|
41
|
+
if isinstance(value, Model):
|
|
42
|
+
return f"{cls.STR_PREFIX}{value.model_options.package_label}/{value.model_options.model_name}/{value.id}"
|
|
43
|
+
return None
|
|
44
|
+
|
|
45
|
+
@classmethod
|
|
46
|
+
def deserialize(cls, data: Any) -> Model | None:
|
|
47
|
+
if value_part := cls._extract_string_value(data):
|
|
48
|
+
try:
|
|
49
|
+
parts = value_part.split("/")
|
|
50
|
+
if len(parts) == 3 and all(parts):
|
|
51
|
+
package, model_name, obj_id = parts
|
|
52
|
+
model = models_registry.get_model(package, model_name)
|
|
53
|
+
return model.query.get(id=obj_id)
|
|
54
|
+
except (ValueError, Exception):
|
|
55
|
+
pass
|
|
56
|
+
return None
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
class DateParameter(JobParameter):
|
|
60
|
+
"""Handle date objects."""
|
|
61
|
+
|
|
62
|
+
STR_PREFIX = "__plain://date/"
|
|
63
|
+
|
|
64
|
+
@classmethod
|
|
65
|
+
def serialize(cls, value: Any) -> str | None:
|
|
66
|
+
if isinstance(value, datetime.date) and not isinstance(
|
|
67
|
+
value, datetime.datetime
|
|
68
|
+
):
|
|
69
|
+
return f"{cls.STR_PREFIX}{value.isoformat()}"
|
|
70
|
+
return None
|
|
71
|
+
|
|
72
|
+
@classmethod
|
|
73
|
+
def deserialize(cls, data: Any) -> datetime.date | None:
|
|
74
|
+
if value_part := cls._extract_string_value(data):
|
|
75
|
+
try:
|
|
76
|
+
return datetime.date.fromisoformat(value_part)
|
|
77
|
+
except ValueError:
|
|
78
|
+
pass
|
|
79
|
+
return None
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
class DateTimeParameter(JobParameter):
|
|
83
|
+
"""Handle datetime objects."""
|
|
84
|
+
|
|
85
|
+
STR_PREFIX = "__plain://datetime/"
|
|
86
|
+
|
|
87
|
+
@classmethod
|
|
88
|
+
def serialize(cls, value: Any) -> str | None:
|
|
89
|
+
if isinstance(value, datetime.datetime):
|
|
90
|
+
return f"{cls.STR_PREFIX}{value.isoformat()}"
|
|
91
|
+
return None
|
|
92
|
+
|
|
93
|
+
@classmethod
|
|
94
|
+
def deserialize(cls, data: Any) -> datetime.datetime | None:
|
|
95
|
+
if value_part := cls._extract_string_value(data):
|
|
96
|
+
try:
|
|
97
|
+
return datetime.datetime.fromisoformat(value_part)
|
|
98
|
+
except ValueError:
|
|
99
|
+
pass
|
|
100
|
+
return None
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
class LegacyModelParameter(JobParameter):
|
|
104
|
+
"""Legacy model parameter handling for backwards compatibility."""
|
|
105
|
+
|
|
106
|
+
STR_PREFIX = "gid://"
|
|
107
|
+
|
|
108
|
+
@classmethod
|
|
109
|
+
def serialize(cls, value: Any) -> str | None:
|
|
110
|
+
# Don't serialize new instances with legacy format
|
|
111
|
+
return None
|
|
112
|
+
|
|
113
|
+
@classmethod
|
|
114
|
+
def deserialize(cls, data: Any) -> Model | None:
|
|
115
|
+
if value_part := cls._extract_string_value(data):
|
|
116
|
+
try:
|
|
117
|
+
package, model, obj_id = value_part.split("/")
|
|
118
|
+
model = models_registry.get_model(package, model)
|
|
119
|
+
return model.query.get(id=obj_id)
|
|
120
|
+
except (ValueError, Exception):
|
|
121
|
+
pass
|
|
122
|
+
return None
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
# Registry of parameter types to check in order
|
|
126
|
+
# The order matters - more specific types should come first
|
|
127
|
+
# DateTimeParameter must come before DateParameter since datetime is a subclass of date
|
|
128
|
+
# LegacyModelParameter is last since it only handles deserialization
|
|
129
|
+
PARAMETER_TYPES = [
|
|
130
|
+
ModelParameter,
|
|
131
|
+
DateTimeParameter,
|
|
132
|
+
DateParameter,
|
|
133
|
+
LegacyModelParameter,
|
|
134
|
+
]
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
class JobParameters:
|
|
138
|
+
"""
|
|
139
|
+
Main interface for serializing and deserializing job parameters.
|
|
140
|
+
Uses the registered parameter types to handle different value types.
|
|
141
|
+
"""
|
|
142
|
+
|
|
143
|
+
@staticmethod
|
|
144
|
+
def to_json(args: tuple[Any, ...], kwargs: dict[str, Any]) -> dict[str, Any]:
|
|
145
|
+
serialized_args = []
|
|
146
|
+
for arg in args:
|
|
147
|
+
serialized = JobParameters._serialize_value(arg)
|
|
148
|
+
serialized_args.append(serialized)
|
|
149
|
+
|
|
150
|
+
serialized_kwargs = {}
|
|
151
|
+
for key, value in kwargs.items():
|
|
152
|
+
serialized = JobParameters._serialize_value(value)
|
|
153
|
+
serialized_kwargs[key] = serialized
|
|
154
|
+
|
|
155
|
+
return {"args": serialized_args, "kwargs": serialized_kwargs}
|
|
156
|
+
|
|
157
|
+
@staticmethod
|
|
158
|
+
def _serialize_value(value: Any) -> Any:
|
|
159
|
+
"""Serialize a single value using the registered parameter types."""
|
|
160
|
+
# Try each parameter type to see if it can serialize this value
|
|
161
|
+
for param_type in PARAMETER_TYPES:
|
|
162
|
+
result = param_type.serialize(value)
|
|
163
|
+
if result is not None:
|
|
164
|
+
return result
|
|
165
|
+
|
|
166
|
+
# If no parameter type can handle it, return as-is
|
|
167
|
+
return value
|
|
168
|
+
|
|
169
|
+
@staticmethod
|
|
170
|
+
def from_json(data: dict[str, Any]) -> tuple[tuple[Any, ...], dict[str, Any]]:
|
|
171
|
+
args = []
|
|
172
|
+
for arg in data["args"]:
|
|
173
|
+
deserialized = JobParameters._deserialize_value(arg)
|
|
174
|
+
args.append(deserialized)
|
|
175
|
+
|
|
176
|
+
kwargs = {}
|
|
177
|
+
for key, value in data["kwargs"].items():
|
|
178
|
+
deserialized = JobParameters._deserialize_value(value)
|
|
179
|
+
kwargs[key] = deserialized
|
|
180
|
+
|
|
181
|
+
return tuple(args), kwargs
|
|
182
|
+
|
|
183
|
+
@staticmethod
|
|
184
|
+
def _deserialize_value(value: Any) -> Any:
|
|
185
|
+
"""Deserialize a single value using the registered parameter types."""
|
|
186
|
+
# Try each parameter type to see if it can deserialize this value
|
|
187
|
+
for param_type in PARAMETER_TYPES:
|
|
188
|
+
result = param_type.deserialize(value)
|
|
189
|
+
if result is not None:
|
|
190
|
+
return result
|
|
191
|
+
|
|
192
|
+
# If no parameter type can handle it, return as-is
|
|
193
|
+
return value
|
plain/jobs/registry.py
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from collections.abc import Callable
|
|
4
|
+
from typing import TYPE_CHECKING, Any, TypeVar
|
|
5
|
+
|
|
6
|
+
from .parameters import JobParameters
|
|
7
|
+
|
|
8
|
+
if TYPE_CHECKING:
|
|
9
|
+
from .jobs import Job
|
|
10
|
+
|
|
11
|
+
T = TypeVar("T", bound=type["Job"])
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class JobsRegistry:
|
|
15
|
+
def __init__(self) -> None:
|
|
16
|
+
self.jobs: dict[str, type[Job]] = {}
|
|
17
|
+
self.ready = False
|
|
18
|
+
|
|
19
|
+
def register_job(self, job_class: type[Job], alias: str = "") -> None:
|
|
20
|
+
name = self.get_job_class_name(job_class)
|
|
21
|
+
self.jobs[name] = job_class
|
|
22
|
+
|
|
23
|
+
if alias:
|
|
24
|
+
self.jobs[alias] = job_class
|
|
25
|
+
|
|
26
|
+
def get_job_class_name(self, job_class: type[Job]) -> str:
|
|
27
|
+
return f"{job_class.__module__}.{job_class.__qualname__}"
|
|
28
|
+
|
|
29
|
+
def get_job_class(self, name: str) -> type[Job]:
|
|
30
|
+
return self.jobs[name]
|
|
31
|
+
|
|
32
|
+
def load_job(self, job_class_name: str, parameters: dict[str, Any]) -> Job:
|
|
33
|
+
if not self.ready:
|
|
34
|
+
raise RuntimeError("Jobs registry is not ready yet")
|
|
35
|
+
|
|
36
|
+
job_class = self.get_job_class(job_class_name)
|
|
37
|
+
args, kwargs = JobParameters.from_json(parameters)
|
|
38
|
+
return job_class(*args, **kwargs)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
jobs_registry = JobsRegistry()
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def register_job(
|
|
45
|
+
job_class: T | None = None, *, alias: str = ""
|
|
46
|
+
) -> T | Callable[[T], T]:
|
|
47
|
+
"""
|
|
48
|
+
A decorator that registers a job class in the jobs registry with an optional alias.
|
|
49
|
+
Can be used both with and without parentheses.
|
|
50
|
+
"""
|
|
51
|
+
if job_class is None:
|
|
52
|
+
|
|
53
|
+
def wrapper(cls: T) -> T:
|
|
54
|
+
jobs_registry.register_job(cls, alias=alias)
|
|
55
|
+
return cls
|
|
56
|
+
|
|
57
|
+
return wrapper
|
|
58
|
+
else:
|
|
59
|
+
jobs_registry.register_job(job_class, alias=alias)
|
|
60
|
+
return job_class
|
plain/jobs/scheduling.py
ADDED
|
@@ -0,0 +1,253 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import datetime
|
|
4
|
+
import subprocess
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from plain.utils import timezone
|
|
8
|
+
|
|
9
|
+
from .jobs import Job
|
|
10
|
+
from .registry import jobs_registry, register_job
|
|
11
|
+
|
|
12
|
+
_MONTH_NAMES = {
|
|
13
|
+
"JAN": 1,
|
|
14
|
+
"FEB": 2,
|
|
15
|
+
"MAR": 3,
|
|
16
|
+
"APR": 4,
|
|
17
|
+
"MAY": 5,
|
|
18
|
+
"JUN": 6,
|
|
19
|
+
"JUL": 7,
|
|
20
|
+
"AUG": 8,
|
|
21
|
+
"SEP": 9,
|
|
22
|
+
"OCT": 10,
|
|
23
|
+
"NOV": 11,
|
|
24
|
+
"DEC": 12,
|
|
25
|
+
}
|
|
26
|
+
_DAY_NAMES = {
|
|
27
|
+
"MON": 0,
|
|
28
|
+
"TUE": 1,
|
|
29
|
+
"WED": 2,
|
|
30
|
+
"THU": 3,
|
|
31
|
+
"FRI": 4,
|
|
32
|
+
"SAT": 5,
|
|
33
|
+
"SUN": 6,
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class _ScheduleComponent:
|
|
38
|
+
def __init__(self, values: list[int], raw: str | int = "") -> None:
|
|
39
|
+
self.values = sorted(values)
|
|
40
|
+
self._raw = raw
|
|
41
|
+
|
|
42
|
+
def __str__(self) -> str:
|
|
43
|
+
if self._raw:
|
|
44
|
+
return str(self._raw)
|
|
45
|
+
return ",".join(str(v) for v in self.values)
|
|
46
|
+
|
|
47
|
+
def __eq__(self, other: Any) -> bool:
|
|
48
|
+
return self.values == other.values
|
|
49
|
+
|
|
50
|
+
@classmethod
|
|
51
|
+
def parse(
|
|
52
|
+
cls,
|
|
53
|
+
value: int | str,
|
|
54
|
+
min_allowed: int,
|
|
55
|
+
max_allowed: int,
|
|
56
|
+
str_conversions: dict[str, int] | None = None,
|
|
57
|
+
) -> _ScheduleComponent:
|
|
58
|
+
if str_conversions is None:
|
|
59
|
+
str_conversions = {}
|
|
60
|
+
|
|
61
|
+
if isinstance(value, int):
|
|
62
|
+
if value < min_allowed or value > max_allowed:
|
|
63
|
+
raise ValueError(
|
|
64
|
+
f"Schedule component should be between {min_allowed} and {max_allowed}"
|
|
65
|
+
)
|
|
66
|
+
return cls([value], raw=value)
|
|
67
|
+
|
|
68
|
+
if not isinstance(value, str):
|
|
69
|
+
raise ValueError("Schedule component should be an int or str")
|
|
70
|
+
|
|
71
|
+
# First split any subcomponents and re-parse them
|
|
72
|
+
if "," in value:
|
|
73
|
+
return cls(
|
|
74
|
+
sum(
|
|
75
|
+
(
|
|
76
|
+
cls.parse(
|
|
77
|
+
sub_value, min_allowed, max_allowed, str_conversions
|
|
78
|
+
).values
|
|
79
|
+
for sub_value in value.split(",")
|
|
80
|
+
),
|
|
81
|
+
[],
|
|
82
|
+
),
|
|
83
|
+
raw=value,
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
if value == "*":
|
|
87
|
+
return cls(list(range(min_allowed, max_allowed + 1)), raw=value)
|
|
88
|
+
|
|
89
|
+
def _convert(value: str) -> int:
|
|
90
|
+
result = str_conversions.get(value.upper(), value)
|
|
91
|
+
return int(result)
|
|
92
|
+
|
|
93
|
+
if "/" in value:
|
|
94
|
+
values, step = value.split("/")
|
|
95
|
+
values = cls.parse(values, min_allowed, max_allowed, str_conversions)
|
|
96
|
+
return cls([v for v in values.values if v % int(step) == 0], raw=value)
|
|
97
|
+
|
|
98
|
+
if "-" in value:
|
|
99
|
+
start, end = value.split("-")
|
|
100
|
+
return cls(list(range(_convert(start), _convert(end) + 1)), raw=value)
|
|
101
|
+
|
|
102
|
+
return cls([_convert(value)], raw=value)
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
class Schedule:
|
|
106
|
+
def __init__(
|
|
107
|
+
self,
|
|
108
|
+
*,
|
|
109
|
+
minute: int | str = "*",
|
|
110
|
+
hour: int | str = "*",
|
|
111
|
+
day_of_month: int | str = "*",
|
|
112
|
+
month: int | str = "*",
|
|
113
|
+
day_of_week: int | str = "*",
|
|
114
|
+
raw: str = "",
|
|
115
|
+
) -> None:
|
|
116
|
+
self.minute = _ScheduleComponent.parse(minute, min_allowed=0, max_allowed=59)
|
|
117
|
+
self.hour = _ScheduleComponent.parse(hour, min_allowed=0, max_allowed=23)
|
|
118
|
+
self.day_of_month = _ScheduleComponent.parse(
|
|
119
|
+
day_of_month, min_allowed=1, max_allowed=31
|
|
120
|
+
)
|
|
121
|
+
self.month = _ScheduleComponent.parse(
|
|
122
|
+
month,
|
|
123
|
+
min_allowed=1,
|
|
124
|
+
max_allowed=12,
|
|
125
|
+
str_conversions=_MONTH_NAMES,
|
|
126
|
+
)
|
|
127
|
+
self.day_of_week = _ScheduleComponent.parse(
|
|
128
|
+
day_of_week,
|
|
129
|
+
min_allowed=0,
|
|
130
|
+
max_allowed=6,
|
|
131
|
+
str_conversions=_DAY_NAMES,
|
|
132
|
+
)
|
|
133
|
+
self._raw = raw
|
|
134
|
+
|
|
135
|
+
def __str__(self) -> str:
|
|
136
|
+
if self._raw:
|
|
137
|
+
return self._raw
|
|
138
|
+
return f"{self.minute} {self.hour} {self.day_of_month} {self.month} {self.day_of_week}"
|
|
139
|
+
|
|
140
|
+
def __repr__(self) -> str:
|
|
141
|
+
return f"<Schedule {self}>"
|
|
142
|
+
|
|
143
|
+
@classmethod
|
|
144
|
+
def from_cron(cls, cron: str) -> Schedule:
|
|
145
|
+
raw = cron
|
|
146
|
+
|
|
147
|
+
if cron == "@yearly" or cron == "@annually":
|
|
148
|
+
cron = "0 0 1 1 *"
|
|
149
|
+
elif cron == "@monthly":
|
|
150
|
+
cron = "0 0 1 * *"
|
|
151
|
+
elif cron == "@weekly":
|
|
152
|
+
cron = "0 0 * * 0"
|
|
153
|
+
elif cron == "@daily" or cron == "@midnight":
|
|
154
|
+
cron = "0 0 * * *"
|
|
155
|
+
elif cron == "@hourly":
|
|
156
|
+
cron = "0 * * * *"
|
|
157
|
+
|
|
158
|
+
minute, hour, day_of_month, month, day_of_week = cron.split()
|
|
159
|
+
|
|
160
|
+
return cls(
|
|
161
|
+
minute=minute,
|
|
162
|
+
hour=hour,
|
|
163
|
+
day_of_month=day_of_month,
|
|
164
|
+
month=month,
|
|
165
|
+
day_of_week=day_of_week,
|
|
166
|
+
raw=raw,
|
|
167
|
+
)
|
|
168
|
+
|
|
169
|
+
def next(self, now: datetime.datetime | None = None) -> datetime.datetime:
|
|
170
|
+
"""
|
|
171
|
+
Find the next datetime that matches the schedule after the given datetime.
|
|
172
|
+
"""
|
|
173
|
+
dt = now or timezone.localtime() # Use the defined plain timezone by default
|
|
174
|
+
|
|
175
|
+
# We only care about minutes, so immediately jump to the next minute
|
|
176
|
+
dt += datetime.timedelta(minutes=1)
|
|
177
|
+
dt = dt.replace(second=0, microsecond=0)
|
|
178
|
+
|
|
179
|
+
def _go_to_next_day(v: datetime.datetime) -> datetime.datetime:
|
|
180
|
+
v = v + datetime.timedelta(days=1)
|
|
181
|
+
return v.replace(
|
|
182
|
+
hour=self.hour.values[0],
|
|
183
|
+
minute=self.minute.values[0],
|
|
184
|
+
)
|
|
185
|
+
|
|
186
|
+
# If we don't find a value in the next 500 days,
|
|
187
|
+
# then the schedule is probably never going to match (i.e. Feb 31)
|
|
188
|
+
max_future = dt + datetime.timedelta(days=500)
|
|
189
|
+
|
|
190
|
+
while True:
|
|
191
|
+
is_valid_day = (
|
|
192
|
+
dt.month in self.month.values
|
|
193
|
+
and dt.day in self.day_of_month.values
|
|
194
|
+
and dt.weekday() in self.day_of_week.values
|
|
195
|
+
)
|
|
196
|
+
if is_valid_day:
|
|
197
|
+
# We're on a valid day, now find the next valid hour and minute
|
|
198
|
+
for hour in self.hour.values:
|
|
199
|
+
if hour < dt.hour:
|
|
200
|
+
continue
|
|
201
|
+
for minute in self.minute.values:
|
|
202
|
+
if hour == dt.hour and minute < dt.minute:
|
|
203
|
+
continue
|
|
204
|
+
candidate_datetime = dt.replace(hour=hour, minute=minute)
|
|
205
|
+
if candidate_datetime >= dt:
|
|
206
|
+
return candidate_datetime
|
|
207
|
+
# If no valid time is found today, reset to the first valid minute and hour of the next day
|
|
208
|
+
dt = _go_to_next_day(dt)
|
|
209
|
+
else:
|
|
210
|
+
# Increment the day until a valid month/day/weekday combination is found
|
|
211
|
+
dt = _go_to_next_day(dt)
|
|
212
|
+
|
|
213
|
+
if dt > max_future:
|
|
214
|
+
raise ValueError("No valid schedule match found in the next 500 days")
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
@register_job
|
|
218
|
+
class ScheduledCommand(Job):
|
|
219
|
+
"""Run a shell command on a schedule."""
|
|
220
|
+
|
|
221
|
+
def __init__(self, command: str) -> None:
|
|
222
|
+
self.command = command
|
|
223
|
+
|
|
224
|
+
def __repr__(self) -> str:
|
|
225
|
+
return f"<ScheduledCommand: {self.command}>"
|
|
226
|
+
|
|
227
|
+
def run(self) -> None:
|
|
228
|
+
subprocess.run(self.command, shell=True, check=True)
|
|
229
|
+
|
|
230
|
+
def default_concurrency_key(self) -> str:
|
|
231
|
+
# The ScheduledCommand can be used for different commands,
|
|
232
|
+
# so we need the concurrency_key to separate them for uniqueness
|
|
233
|
+
return self.command
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
def load_schedule(
|
|
237
|
+
schedules: list[tuple[str | Job, str | Schedule]],
|
|
238
|
+
) -> list[tuple[Job, Schedule]]:
|
|
239
|
+
jobs_schedule: list[tuple[Job, Schedule]] = []
|
|
240
|
+
|
|
241
|
+
for job, schedule in schedules:
|
|
242
|
+
if isinstance(job, str):
|
|
243
|
+
if job.startswith("cmd:"):
|
|
244
|
+
job = ScheduledCommand(job[4:])
|
|
245
|
+
else:
|
|
246
|
+
job = jobs_registry.load_job(job, {"args": [], "kwargs": {}})
|
|
247
|
+
|
|
248
|
+
if isinstance(schedule, str):
|
|
249
|
+
schedule = Schedule.from_cron(schedule)
|
|
250
|
+
|
|
251
|
+
jobs_schedule.append((job, schedule))
|
|
252
|
+
|
|
253
|
+
return jobs_schedule
|