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.
@@ -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
@@ -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
@@ -0,0 +1,8 @@
1
+ {% extends "admin/detail.html" %}
2
+
3
+ {% block actions %}
4
+ <form method="post">
5
+ <input type="hidden" name="action" value="retry">
6
+ <button type="submit">Retry job</button>
7
+ </form>
8
+ {% endblock %}