avtomatika 1.0b4__py3-none-any.whl → 1.0b6__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,41 @@
1
+ from dataclasses import dataclass
2
+ from tomllib import load
3
+ from typing import Any
4
+
5
+
6
+ @dataclass
7
+ class ScheduledJobConfig:
8
+ name: str
9
+ blueprint: str
10
+ input_data: dict[str, Any]
11
+ interval_seconds: int | None = None
12
+ daily_at: str | None = None
13
+ weekly_days: list[str] | None = None
14
+ monthly_dates: list[int] | None = None
15
+ time: str | None = None
16
+
17
+
18
+ def load_schedules_from_file(file_path: str) -> list[ScheduledJobConfig]:
19
+ """Loads scheduled job configurations from a TOML file."""
20
+ with open(file_path, "rb") as f:
21
+ data = load(f)
22
+
23
+ schedules = []
24
+ for name, config in data.items():
25
+ # Skip sections that might be metadata (though TOML structure usually implies all top-level keys are jobs)
26
+ if not isinstance(config, dict):
27
+ continue
28
+
29
+ schedules.append(
30
+ ScheduledJobConfig(
31
+ name=name,
32
+ blueprint=config.get("blueprint"),
33
+ input_data=config.get("input_data", {}),
34
+ interval_seconds=config.get("interval_seconds"),
35
+ daily_at=config.get("daily_at"),
36
+ weekly_days=config.get("weekly_days"),
37
+ monthly_dates=config.get("monthly_dates"),
38
+ time=config.get("time"),
39
+ )
40
+ )
41
+ return schedules
avtomatika/security.py CHANGED
@@ -4,11 +4,9 @@ from typing import Any, Awaitable, Callable
4
4
  from aiohttp import web
5
5
 
6
6
  from .config import Config
7
+ from .constants import AUTH_HEADER_CLIENT, AUTH_HEADER_WORKER
7
8
  from .storage.base import StorageBackend
8
9
 
9
- AUTH_HEADER_AVTOMATIKA = "X-Avtomatika-Token"
10
- AUTH_HEADER_WORKER = "X-Worker-Token"
11
-
12
10
  Handler = Callable[[web.Request], Awaitable[web.Response]]
13
11
 
14
12
 
@@ -21,10 +19,10 @@ def client_auth_middleware_factory(
21
19
 
22
20
  @web.middleware
23
21
  async def middleware(request: web.Request, handler: Handler) -> web.Response:
24
- token = request.headers.get(AUTH_HEADER_AVTOMATIKA)
22
+ token = request.headers.get(AUTH_HEADER_CLIENT)
25
23
  if not token:
26
24
  return web.json_response(
27
- {"error": "Missing X-Avtomatika-Token header"},
25
+ {"error": f"Missing {AUTH_HEADER_CLIENT} header"},
28
26
  status=401,
29
27
  )
30
28
 
@@ -1,11 +1,11 @@
1
- import contextlib
1
+ from contextlib import suppress
2
2
 
3
3
  from .base import StorageBackend
4
4
  from .memory import MemoryStorage
5
5
 
6
6
  __all__ = ["StorageBackend", "MemoryStorage"]
7
7
 
8
- with contextlib.suppress(ImportError):
8
+ with suppress(ImportError):
9
9
  from .redis import RedisStorage # noqa: F401
10
10
 
11
11
  __all__.append("RedisStorage")
@@ -1,5 +1,5 @@
1
1
  from abc import ABC, abstractmethod
2
- from typing import Any, Dict, Optional
2
+ from typing import Any
3
3
 
4
4
 
5
5
  class StorageBackend(ABC):
@@ -8,7 +8,7 @@ class StorageBackend(ABC):
8
8
  """
9
9
 
10
10
  @abstractmethod
11
- async def get_job_state(self, job_id: str) -> Optional[Dict[str, Any]]:
11
+ async def get_job_state(self, job_id: str) -> dict[str, Any] | None:
12
12
  """Get the full state of a job by its ID.
13
13
 
14
14
  :param job_id: Unique identifier for the job.
@@ -20,8 +20,8 @@ class StorageBackend(ABC):
20
20
  async def update_worker_data(
21
21
  self,
22
22
  worker_id: str,
23
- update_data: Dict[str, Any],
24
- ) -> Optional[Dict[str, Any]]:
23
+ update_data: dict[str, Any],
24
+ ) -> dict[str, Any] | None:
25
25
  """Partially update worker information without affecting its TTL.
26
26
  Used for background processes like the reputation calculator.
27
27
 
@@ -54,9 +54,9 @@ class StorageBackend(ABC):
54
54
  async def update_worker_status(
55
55
  self,
56
56
  worker_id: str,
57
- status_update: Dict[str, Any],
57
+ status_update: dict[str, Any],
58
58
  ttl: int,
59
- ) -> Optional[Dict[str, Any]]:
59
+ ) -> dict[str, Any] | None:
60
60
  """Partially update worker information and extend its TTL.
61
61
  Used for heartbeat messages.
62
62
 
@@ -68,7 +68,7 @@ class StorageBackend(ABC):
68
68
  raise NotImplementedError
69
69
 
70
70
  @abstractmethod
71
- async def save_job_state(self, job_id: str, state: Dict[str, Any]) -> None:
71
+ async def save_job_state(self, job_id: str, state: dict[str, Any]) -> None:
72
72
  """Save the full state of a job.
73
73
 
74
74
  :param job_id: Unique identifier for the job.
@@ -80,8 +80,8 @@ class StorageBackend(ABC):
80
80
  async def update_job_state(
81
81
  self,
82
82
  job_id: str,
83
- update_data: Dict[str, Any],
84
- ) -> Dict[str, Any]:
83
+ update_data: dict[str, Any],
84
+ ) -> dict[str, Any]:
85
85
  """Partially update the state of a job.
86
86
 
87
87
  :param job_id: Unique identifier for the job.
@@ -94,7 +94,7 @@ class StorageBackend(ABC):
94
94
  async def register_worker(
95
95
  self,
96
96
  worker_id: str,
97
- worker_info: Dict[str, Any],
97
+ worker_info: dict[str, Any],
98
98
  ttl: int,
99
99
  ) -> None:
100
100
  """Registers a new worker or updates information about an existing one.
@@ -109,7 +109,7 @@ class StorageBackend(ABC):
109
109
  async def enqueue_task_for_worker(
110
110
  self,
111
111
  worker_id: str,
112
- task_payload: Dict[str, Any],
112
+ task_payload: dict[str, Any],
113
113
  priority: float,
114
114
  ) -> None:
115
115
  """Adds a task to the priority queue for a specific worker.
@@ -125,7 +125,7 @@ class StorageBackend(ABC):
125
125
  self,
126
126
  worker_id: str,
127
127
  timeout: int,
128
- ) -> Optional[Dict[str, Any]]:
128
+ ) -> dict[str, Any] | None:
129
129
  """Retrieves the highest priority task from the queue for a worker (blocking operation).
130
130
 
131
131
  :param worker_id: The ID of the worker for whom to retrieve the task.
@@ -135,7 +135,7 @@ class StorageBackend(ABC):
135
135
  raise NotImplementedError
136
136
 
137
137
  @abstractmethod
138
- async def get_available_workers(self) -> list[Dict[str, Any]]:
138
+ async def get_available_workers(self) -> list[dict[str, Any]]:
139
139
  """Get a list of all active (not expired) workers.
140
140
 
141
141
  :return: A list of dictionaries, where each dictionary represents information about a worker.
@@ -165,8 +165,19 @@ class StorageBackend(ABC):
165
165
  raise NotImplementedError
166
166
 
167
167
  @abstractmethod
168
- async def dequeue_job(self) -> Optional[str]:
169
- """Retrieve a job ID from the execution queue (blocking operation)."""
168
+ async def dequeue_job(self) -> tuple[str, str] | None:
169
+ """Retrieve a job ID and its message ID from the execution queue.
170
+
171
+ :return: A tuple of (job_id, message_id) or None if the timeout has expired.
172
+ """
173
+ raise NotImplementedError
174
+
175
+ @abstractmethod
176
+ async def ack_job(self, message_id: str) -> None:
177
+ """Acknowledge successful processing of a job from the queue.
178
+
179
+ :param message_id: The identifier of the message to acknowledge.
180
+ """
170
181
  raise NotImplementedError
171
182
 
172
183
  @abstractmethod
@@ -196,12 +207,12 @@ class StorageBackend(ABC):
196
207
  raise NotImplementedError
197
208
 
198
209
  @abstractmethod
199
- async def save_client_config(self, token: str, config: Dict[str, Any]) -> None:
210
+ async def save_client_config(self, token: str, config: dict[str, Any]) -> None:
200
211
  """Saves the static configuration of a client."""
201
212
  raise NotImplementedError
202
213
 
203
214
  @abstractmethod
204
- async def get_client_config(self, token: str) -> Optional[Dict[str, Any]]:
215
+ async def get_client_config(self, token: str) -> dict[str, Any] | None:
205
216
  """Gets the static configuration of a client."""
206
217
  raise NotImplementedError
207
218
 
@@ -225,7 +236,7 @@ class StorageBackend(ABC):
225
236
  raise NotImplementedError
226
237
 
227
238
  @abstractmethod
228
- async def get_priority_queue_stats(self, task_type: str) -> Dict[str, Any]:
239
+ async def get_priority_queue_stats(self, task_type: str) -> dict[str, Any]:
229
240
  """Get statistics on the priority queue for a given task type.
230
241
 
231
242
  :param task_type: The type of task (used as part of the queue key).
@@ -244,12 +255,12 @@ class StorageBackend(ABC):
244
255
  raise NotImplementedError
245
256
 
246
257
  @abstractmethod
247
- async def get_worker_token(self, worker_id: str) -> Optional[str]:
258
+ async def get_worker_token(self, worker_id: str) -> str | None:
248
259
  """Retrieves an individual token for a specific worker."""
249
260
  raise NotImplementedError
250
261
 
251
262
  @abstractmethod
252
- async def get_worker_info(self, worker_id: str) -> Optional[Dict[str, Any]]:
263
+ async def get_worker_info(self, worker_id: str) -> dict[str, Any] | None:
253
264
  """Get complete information about a worker by its ID."""
254
265
  raise NotImplementedError
255
266
 
@@ -258,13 +269,27 @@ class StorageBackend(ABC):
258
269
  """Completely clears the storage. Used mainly for tests."""
259
270
  raise NotImplementedError
260
271
 
261
- @abstractmethod
262
272
  async def get_active_worker_count(self) -> int:
263
- """Get the current number of active (registered) workers.
264
- Used for metrics.
273
+ """Returns the number of currently active workers."""
274
+ raise NotImplementedError
275
+
276
+ async def set_nx_ttl(self, key: str, value: str, ttl: int) -> bool:
277
+ """
278
+ Atomically sets key to value if it does not exist.
279
+ Sets a TTL (in seconds) on the key.
280
+ Returns True if set, False if already exists.
281
+ Critical for distributed locking.
265
282
  """
266
283
  raise NotImplementedError
267
284
 
285
+ async def get_str(self, key: str) -> str | None:
286
+ """Gets a simple string value from storage."""
287
+ raise NotImplementedError
288
+
289
+ async def set_str(self, key: str, value: str, ttl: int | None = None) -> None:
290
+ """Sets a simple string value in storage with optional TTL."""
291
+ raise NotImplementedError
292
+
268
293
  @abstractmethod
269
294
  async def acquire_lock(self, key: str, holder_id: str, ttl: int) -> bool:
270
295
  """
@@ -1,7 +1,7 @@
1
1
  from asyncio import Lock, PriorityQueue, Queue, QueueEmpty, wait_for
2
2
  from asyncio import TimeoutError as AsyncTimeoutError
3
3
  from time import monotonic
4
- from typing import Any, Dict, List, Optional
4
+ from typing import Any
5
5
 
6
6
  from .base import StorageBackend
7
7
 
@@ -13,35 +13,49 @@ class MemoryStorage(StorageBackend):
13
13
  """
14
14
 
15
15
  def __init__(self):
16
- self._jobs: Dict[str, Dict[str, Any]] = {}
17
- self._workers: Dict[str, Dict[str, Any]] = {}
18
- self._worker_ttls: Dict[str, float] = {}
19
- self._worker_task_queues: Dict[str, PriorityQueue] = {}
16
+ self._jobs: dict[str, dict[str, Any]] = {}
17
+ self._workers: dict[str, dict[str, Any]] = {}
18
+ self._worker_ttls: dict[str, float] = {}
19
+ self._worker_task_queues: dict[str, PriorityQueue] = {}
20
20
  self._job_queue = Queue()
21
- self._quarantine_queue: List[str] = []
22
- self._watched_jobs: Dict[str, float] = {}
23
- self._client_configs: Dict[str, Dict[str, Any]] = {}
24
- self._quotas: Dict[str, int] = {}
25
- self._worker_tokens: Dict[str, str] = {}
26
- self._generic_keys: Dict[str, Any] = {}
27
- self._generic_key_ttls: Dict[str, float] = {}
28
- self._locks: Dict[str, tuple[str, float]] = {} # key -> (holder_id, expiry_time)
21
+ self._quarantine_queue: list[str] = []
22
+ self._watched_jobs: dict[str, float] = {}
23
+ self._client_configs: dict[str, dict[str, Any]] = {}
24
+ self._quotas: dict[str, int] = {}
25
+ self._worker_tokens: dict[str, str] = {}
26
+ self._generic_keys: dict[str, Any] = {}
27
+ self._generic_key_ttls: dict[str, float] = {}
28
+ self._locks: dict[str, tuple[str, float]] = {}
29
29
 
30
30
  self._lock = Lock()
31
31
 
32
- async def get_job_state(self, job_id: str) -> Optional[Dict[str, Any]]:
32
+ async def get_job_state(self, job_id: str) -> dict[str, Any] | None:
33
33
  async with self._lock:
34
34
  return self._jobs.get(job_id)
35
35
 
36
- async def save_job_state(self, job_id: str, state: Dict[str, Any]) -> None:
36
+ async def _clean_expired(self):
37
+ """Helper to remove expired keys."""
38
+ now = monotonic()
39
+
40
+ expired_generic = [k for k, t in self._generic_key_ttls.items() if t < now]
41
+ for k in expired_generic:
42
+ self._generic_key_ttls.pop(k, None)
43
+ self._generic_keys.pop(k, None)
44
+
45
+ expired_workers = [k for k, t in self._worker_ttls.items() if t < now]
46
+ for k in expired_workers:
47
+ self._worker_ttls.pop(k, None)
48
+ self._workers.pop(k, None)
49
+
50
+ async def save_job_state(self, job_id: str, state: dict[str, Any]):
37
51
  async with self._lock:
38
52
  self._jobs[job_id] = state
39
53
 
40
54
  async def update_job_state(
41
55
  self,
42
56
  job_id: str,
43
- update_data: Dict[str, Any],
44
- ) -> Dict[str, Any]:
57
+ update_data: dict[str, Any],
58
+ ) -> dict[str, Any]:
45
59
  async with self._lock:
46
60
  if job_id not in self._jobs:
47
61
  self._jobs[job_id] = {}
@@ -51,7 +65,7 @@ class MemoryStorage(StorageBackend):
51
65
  async def register_worker(
52
66
  self,
53
67
  worker_id: str,
54
- worker_info: Dict[str, Any],
68
+ worker_info: dict[str, Any],
55
69
  ttl: int,
56
70
  ) -> None:
57
71
  """Registers a worker and creates a task queue for it."""
@@ -66,21 +80,20 @@ class MemoryStorage(StorageBackend):
66
80
  async def enqueue_task_for_worker(
67
81
  self,
68
82
  worker_id: str,
69
- task_payload: Dict[str, Any],
83
+ task_payload: dict[str, Any],
70
84
  priority: float,
71
85
  ) -> None:
72
86
  """Puts a task on the priority queue for a worker."""
73
87
  async with self._lock:
74
88
  if worker_id not in self._worker_task_queues:
75
89
  self._worker_task_queues[worker_id] = PriorityQueue()
76
- # asyncio.PriorityQueue is a min-heap, so we invert the priority
77
90
  await self._worker_task_queues[worker_id].put((-priority, task_payload))
78
91
 
79
92
  async def dequeue_task_for_worker(
80
93
  self,
81
94
  worker_id: str,
82
95
  timeout: int,
83
- ) -> Optional[Dict[str, Any]]:
96
+ ) -> dict[str, Any] | None:
84
97
  """Retrieves a task from the worker's priority queue with a timeout."""
85
98
  queue = None
86
99
  async with self._lock:
@@ -104,9 +117,9 @@ class MemoryStorage(StorageBackend):
104
117
  async def update_worker_status(
105
118
  self,
106
119
  worker_id: str,
107
- status_update: Dict[str, Any],
120
+ status_update: dict[str, Any],
108
121
  ttl: int,
109
- ) -> Optional[Dict[str, Any]]:
122
+ ) -> dict[str, Any] | None:
110
123
  async with self._lock:
111
124
  if worker_id in self._workers:
112
125
  self._workers[worker_id].update(status_update)
@@ -117,8 +130,8 @@ class MemoryStorage(StorageBackend):
117
130
  async def update_worker_data(
118
131
  self,
119
132
  worker_id: str,
120
- update_data: Dict[str, Any],
121
- ) -> Optional[Dict[str, Any]]:
133
+ update_data: dict[str, Any],
134
+ ) -> dict[str, Any] | None:
122
135
  async with self._lock:
123
136
  if worker_id in self._workers:
124
137
  self._workers[worker_id].update(update_data)
@@ -155,13 +168,17 @@ class MemoryStorage(StorageBackend):
155
168
  async def enqueue_job(self, job_id: str) -> None:
156
169
  await self._job_queue.put(job_id)
157
170
 
158
- async def dequeue_job(self) -> str | None:
171
+ async def dequeue_job(self) -> tuple[str, str] | None:
159
172
  """Waits indefinitely for a job ID from the queue and returns it.
160
- This simulates the blocking behavior of Redis's BLPOP.
173
+ Returns a tuple of (job_id, message_id). In MemoryStorage, message_id is dummy.
161
174
  """
162
175
  job_id = await self._job_queue.get()
163
176
  self._job_queue.task_done()
164
- return job_id
177
+ return job_id, "memory-msg-id"
178
+
179
+ async def ack_job(self, message_id: str) -> None:
180
+ """No-op for MemoryStorage as it doesn't support persistent streams."""
181
+ pass
165
182
 
166
183
  async def quarantine_job(self, job_id: str) -> None:
167
184
  async with self._lock:
@@ -187,11 +204,11 @@ class MemoryStorage(StorageBackend):
187
204
  self._generic_key_ttls[key] = now + ttl
188
205
  return self._generic_keys[key]
189
206
 
190
- async def save_client_config(self, token: str, config: Dict[str, Any]) -> None:
207
+ async def save_client_config(self, token: str, config: dict[str, Any]) -> None:
191
208
  async with self._lock:
192
209
  self._client_configs[token] = config
193
210
 
194
- async def get_client_config(self, token: str) -> Optional[Dict[str, Any]]:
211
+ async def get_client_config(self, token: str) -> dict[str, Any] | None:
195
212
  async with self._lock:
196
213
  return self._client_configs.get(token)
197
214
 
@@ -217,7 +234,6 @@ class MemoryStorage(StorageBackend):
217
234
  self._workers.clear()
218
235
  self._worker_ttls.clear()
219
236
  self._worker_task_queues.clear()
220
- # Empty the queue
221
237
  while not self._job_queue.empty():
222
238
  try:
223
239
  self._job_queue.get_nowait()
@@ -232,17 +248,38 @@ class MemoryStorage(StorageBackend):
232
248
  self._locks.clear()
233
249
 
234
250
  async def get_job_queue_length(self) -> int:
235
- # No lock needed for asyncio.Queue.qsize()
236
251
  return self._job_queue.qsize()
237
252
 
238
253
  async def get_active_worker_count(self) -> int:
239
254
  async with self._lock:
240
- now = monotonic()
241
- # Create a copy of keys to avoid issues with concurrent modifications
242
- worker_ids = list(self._workers.keys())
243
- return sum(self._worker_ttls.get(worker_id, 0) > now for worker_id in worker_ids)
255
+ await self._clean_expired()
256
+ return len(self._workers)
257
+
258
+ async def set_nx_ttl(self, key: str, value: str, ttl: int) -> bool:
259
+ async with self._lock:
260
+ await self._clean_expired()
261
+ if key in self._generic_keys:
262
+ return False
244
263
 
245
- async def get_worker_info(self, worker_id: str) -> Optional[Dict[str, Any]]:
264
+ self._generic_keys[key] = value
265
+ self._generic_key_ttls[key] = monotonic() + ttl
266
+ return True
267
+
268
+ async def get_str(self, key: str) -> str | None:
269
+ async with self._lock:
270
+ await self._clean_expired()
271
+ val = self._generic_keys.get(key)
272
+ return str(val) if val is not None else None
273
+
274
+ async def set_str(self, key: str, value: str, ttl: int | None = None) -> None:
275
+ async with self._lock:
276
+ self._generic_keys[key] = value
277
+ if ttl:
278
+ self._generic_key_ttls[key] = monotonic() + ttl
279
+ else:
280
+ self._generic_key_ttls.pop(key, None)
281
+
282
+ async def get_worker_info(self, worker_id: str) -> dict[str, Any] | None:
246
283
  async with self._lock:
247
284
  return self._workers.get(worker_id)
248
285
 
@@ -250,7 +287,7 @@ class MemoryStorage(StorageBackend):
250
287
  async with self._lock:
251
288
  self._worker_tokens[worker_id] = token
252
289
 
253
- async def get_worker_token(self, worker_id: str) -> Optional[str]:
290
+ async def get_worker_token(self, worker_id: str) -> str | None:
254
291
  async with self._lock:
255
292
  return self._worker_tokens.get(worker_id)
256
293
 
@@ -258,7 +295,7 @@ class MemoryStorage(StorageBackend):
258
295
  key = f"task_cancel:{task_id}"
259
296
  await self.increment_key_with_ttl(key, 3600)
260
297
 
261
- async def get_priority_queue_stats(self, task_type: str) -> Dict[str, Any]:
298
+ async def get_priority_queue_stats(self, task_type: str) -> dict[str, Any]:
262
299
  """
263
300
  Returns empty data, as `asyncio.PriorityQueue` does not
264
301
  support introspection to get statistics.
@@ -278,14 +315,8 @@ class MemoryStorage(StorageBackend):
278
315
  async with self._lock:
279
316
  now = monotonic()
280
317
  current_lock = self._locks.get(key)
281
-
282
- # If lock exists and hasn't expired
283
318
  if current_lock and current_lock[1] > now:
284
- # If explicitly owned by us, we can extend/re-enter (optional behavior)
285
- # But for strict locking, if it's held, return False (unless it's us? let's simpler: just False if held)
286
319
  return False
287
-
288
- # Acquire lock
289
320
  self._locks[key] = (holder_id, now + ttl)
290
321
  return True
291
322
 
@@ -294,7 +325,6 @@ class MemoryStorage(StorageBackend):
294
325
  current_lock = self._locks.get(key)
295
326
  if current_lock:
296
327
  owner, expiry = current_lock
297
- # Only release if we are the owner
298
328
  if owner == holder_id:
299
329
  del self._locks[key]
300
330
  return True