avtomatika 1.0b4__py3-none-any.whl → 1.0b5__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.
avtomatika/engine.py CHANGED
@@ -1,12 +1,13 @@
1
1
  from asyncio import Task, create_task, gather, get_running_loop, wait_for
2
2
  from asyncio import TimeoutError as AsyncTimeoutError
3
3
  from logging import getLogger
4
- from typing import Callable, Dict
4
+ from typing import Any, Callable
5
5
  from uuid import uuid4
6
6
 
7
7
  from aiohttp import ClientSession, WSMsgType, web
8
8
  from aiohttp.web import AppKey
9
9
  from aioprometheus import render
10
+ from orjson import OPT_INDENT_2, dumps, loads
10
11
 
11
12
  from . import metrics
12
13
  from .blueprint import StateMachineBlueprint
@@ -42,15 +43,21 @@ WATCHER_TASK_KEY = AppKey("watcher_task", Task)
42
43
  REPUTATION_CALCULATOR_TASK_KEY = AppKey("reputation_calculator_task", Task)
43
44
  HEALTH_CHECKER_TASK_KEY = AppKey("health_checker_task", Task)
44
45
 
45
-
46
46
  metrics.init_metrics()
47
47
 
48
-
49
48
  logger = getLogger(__name__)
50
49
 
51
50
 
51
+ def json_dumps(obj: Any) -> str:
52
+ return dumps(obj).decode("utf-8")
53
+
54
+
55
+ def json_response(data: Any, **kwargs: Any) -> web.Response:
56
+ return web.json_response(data, dumps=json_dumps, **kwargs)
57
+
58
+
52
59
  async def status_handler(_request: web.Request) -> web.Response:
53
- return web.json_response({"status": "ok"})
60
+ return json_response({"status": "ok"})
54
61
 
55
62
 
56
63
  async def metrics_handler(_request: web.Request) -> web.Response:
@@ -63,7 +70,7 @@ class OrchestratorEngine:
63
70
  setup_telemetry()
64
71
  self.storage = storage
65
72
  self.config = config
66
- self.blueprints: Dict[str, StateMachineBlueprint] = {}
73
+ self.blueprints: dict[str, StateMachineBlueprint] = {}
67
74
  self.history_storage: HistoryStorageBase = NoOpHistoryStorage()
68
75
  self.ws_manager = WebSocketManager()
69
76
  self.app = web.Application(middlewares=[compression_middleware])
@@ -245,9 +252,9 @@ class OrchestratorEngine:
245
252
  def _create_job_handler(self, blueprint: StateMachineBlueprint) -> Callable:
246
253
  async def handler(request: web.Request) -> web.Response:
247
254
  try:
248
- initial_data = await request.json()
255
+ initial_data = await request.json(loads=loads)
249
256
  except Exception:
250
- return web.json_response({"error": "Invalid JSON body"}, status=400)
257
+ return json_response({"error": "Invalid JSON body"}, status=400)
251
258
 
252
259
  client_config = request["client_config"]
253
260
  carrier = {str(k): v for k, v in request.headers.items()}
@@ -266,37 +273,37 @@ class OrchestratorEngine:
266
273
  await self.storage.save_job_state(job_id, job_state)
267
274
  await self.storage.enqueue_job(job_id)
268
275
  metrics.jobs_total.inc({metrics.LABEL_BLUEPRINT: blueprint.name})
269
- return web.json_response({"status": "accepted", "job_id": job_id}, status=202)
276
+ return json_response({"status": "accepted", "job_id": job_id}, status=202)
270
277
 
271
278
  return handler
272
279
 
273
280
  async def _get_job_status_handler(self, request: web.Request) -> web.Response:
274
281
  job_id = request.match_info.get("job_id")
275
282
  if not job_id:
276
- return web.json_response({"error": "job_id is required in path"}, status=400)
283
+ return json_response({"error": "job_id is required in path"}, status=400)
277
284
  job_state = await self.storage.get_job_state(job_id)
278
285
  if not job_state:
279
- return web.json_response({"error": "Job not found"}, status=404)
280
- return web.json_response(job_state, status=200)
286
+ return json_response({"error": "Job not found"}, status=404)
287
+ return json_response(job_state, status=200)
281
288
 
282
289
  async def _cancel_job_handler(self, request: web.Request) -> web.Response:
283
290
  job_id = request.match_info.get("job_id")
284
291
  if not job_id:
285
- return web.json_response({"error": "job_id is required in path"}, status=400)
292
+ return json_response({"error": "job_id is required in path"}, status=400)
286
293
 
287
294
  job_state = await self.storage.get_job_state(job_id)
288
295
  if not job_state:
289
- return web.json_response({"error": "Job not found"}, status=404)
296
+ return json_response({"error": "Job not found"}, status=404)
290
297
 
291
298
  if job_state.get("status") != "waiting_for_worker":
292
- return web.json_response(
299
+ return json_response(
293
300
  {"error": "Job is not in a state that can be cancelled (must be waiting for a worker)."},
294
301
  status=409,
295
302
  )
296
303
 
297
304
  worker_id = job_state.get("task_worker_id")
298
305
  if not worker_id:
299
- return web.json_response(
306
+ return json_response(
300
307
  {"error": "Cannot cancel job: worker_id not found in job state."},
301
308
  status=500,
302
309
  )
@@ -304,7 +311,7 @@ class OrchestratorEngine:
304
311
  worker_info = await self.storage.get_worker_info(worker_id)
305
312
  task_id = job_state.get("current_task_id")
306
313
  if not task_id:
307
- return web.json_response(
314
+ return json_response(
308
315
  {"error": "Cannot cancel job: task_id not found in job state."},
309
316
  status=500,
310
317
  )
@@ -317,28 +324,28 @@ class OrchestratorEngine:
317
324
  command = {"command": "cancel_task", "task_id": task_id, "job_id": job_id}
318
325
  sent = await self.ws_manager.send_command(worker_id, command)
319
326
  if sent:
320
- return web.json_response({"status": "cancellation_request_sent"})
327
+ return json_response({"status": "cancellation_request_sent"})
321
328
  else:
322
329
  logger.warning(f"Failed to send WebSocket cancellation for task {task_id}, but Redis flag is set.")
323
330
  # Proceed to return success, as the Redis flag will handle it
324
331
 
325
- return web.json_response({"status": "cancellation_request_accepted"})
332
+ return json_response({"status": "cancellation_request_accepted"})
326
333
 
327
334
  async def _get_job_history_handler(self, request: web.Request) -> web.Response:
328
335
  job_id = request.match_info.get("job_id")
329
336
  if not job_id:
330
- return web.json_response({"error": "job_id is required in path"}, status=400)
337
+ return json_response({"error": "job_id is required in path"}, status=400)
331
338
  history = await self.history_storage.get_job_history(job_id)
332
- return web.json_response(history)
339
+ return json_response(history)
333
340
 
334
341
  async def _get_blueprint_graph_handler(self, request: web.Request) -> web.Response:
335
342
  blueprint_name = request.match_info.get("blueprint_name")
336
343
  if not blueprint_name:
337
- return web.json_response({"error": "blueprint_name is required in path"}, status=400)
344
+ return json_response({"error": "blueprint_name is required in path"}, status=400)
338
345
 
339
346
  blueprint = self.blueprints.get(blueprint_name)
340
347
  if not blueprint:
341
- return web.json_response({"error": "Blueprint not found"}, status=404)
348
+ return json_response({"error": "Blueprint not found"}, status=404)
342
349
 
343
350
  try:
344
351
  graph_dot = blueprint.render_graph()
@@ -346,21 +353,21 @@ class OrchestratorEngine:
346
353
  except FileNotFoundError:
347
354
  error_msg = "Graphviz is not installed on the server. Cannot generate graph."
348
355
  logger.error(error_msg)
349
- return web.json_response({"error": error_msg}, status=501)
356
+ return json_response({"error": error_msg}, status=501)
350
357
 
351
358
  async def _get_workers_handler(self, request: web.Request) -> web.Response:
352
359
  workers = await self.storage.get_available_workers()
353
- return web.json_response(workers)
360
+ return json_response(workers)
354
361
 
355
362
  async def _get_jobs_handler(self, request: web.Request) -> web.Response:
356
363
  try:
357
364
  limit = int(request.query.get("limit", "100"))
358
365
  offset = int(request.query.get("offset", "0"))
359
366
  except ValueError:
360
- return web.json_response({"error": "Invalid limit/offset parameter"}, status=400)
367
+ return json_response({"error": "Invalid limit/offset parameter"}, status=400)
361
368
 
362
369
  jobs = await self.history_storage.get_jobs(limit=limit, offset=offset)
363
- return web.json_response(jobs)
370
+ return json_response(jobs)
364
371
 
365
372
  async def _get_dashboard_handler(self, request: web.Request) -> web.Response:
366
373
  worker_count = await self.storage.get_active_worker_count()
@@ -371,13 +378,13 @@ class OrchestratorEngine:
371
378
  "workers": {"total": worker_count},
372
379
  "jobs": {"queued": queue_length, **job_summary},
373
380
  }
374
- return web.json_response(dashboard_data)
381
+ return json_response(dashboard_data)
375
382
 
376
383
  async def _task_result_handler(self, request: web.Request) -> web.Response:
377
384
  import logging
378
385
 
379
386
  try:
380
- data = await request.json()
387
+ data = await request.json(loads=loads)
381
388
  job_id = data.get("job_id")
382
389
  task_id = data.get("task_id")
383
390
  result = data.get("result", {})
@@ -385,16 +392,16 @@ class OrchestratorEngine:
385
392
  error_message = result.get("error")
386
393
  payload_worker_id = data.get("worker_id")
387
394
  except Exception:
388
- return web.json_response({"error": "Invalid JSON body"}, status=400)
395
+ return json_response({"error": "Invalid JSON body"}, status=400)
389
396
 
390
397
  # Security check: Ensure the worker_id from the payload matches the authenticated worker
391
398
  authenticated_worker_id = request.get("worker_id")
392
399
  if not authenticated_worker_id:
393
400
  # This should not happen if the auth middleware is working correctly
394
- return web.json_response({"error": "Could not identify authenticated worker."}, status=500)
401
+ return json_response({"error": "Could not identify authenticated worker."}, status=500)
395
402
 
396
403
  if payload_worker_id and payload_worker_id != authenticated_worker_id:
397
- return web.json_response(
404
+ return json_response(
398
405
  {
399
406
  "error": f"Forbidden: Authenticated worker '{authenticated_worker_id}' "
400
407
  f"cannot submit results for another worker '{payload_worker_id}'.",
@@ -403,11 +410,11 @@ class OrchestratorEngine:
403
410
  )
404
411
 
405
412
  if not job_id or not task_id:
406
- return web.json_response({"error": "job_id and task_id are required"}, status=400)
413
+ return json_response({"error": "job_id and task_id are required"}, status=400)
407
414
 
408
415
  job_state = await self.storage.get_job_state(job_id)
409
416
  if not job_state:
410
- return web.json_response({"error": "Job not found"}, status=404)
417
+ return json_response({"error": "Job not found"}, status=404)
411
418
 
412
419
  # Handle parallel task completion
413
420
  if job_state.get("status") == "waiting_for_parallel_tasks":
@@ -428,7 +435,7 @@ class OrchestratorEngine:
428
435
  )
429
436
  await self.storage.save_job_state(job_id, job_state)
430
437
 
431
- return web.json_response({"status": "parallel_branch_result_accepted"}, status=200)
438
+ return json_response({"status": "parallel_branch_result_accepted"}, status=200)
432
439
 
433
440
  await self.storage.remove_job_from_watch(job_id)
434
441
 
@@ -477,7 +484,7 @@ class OrchestratorEngine:
477
484
  else: # TRANSIENT_ERROR or any other/unspecified error
478
485
  await self._handle_task_failure(job_state, task_id, error_message)
479
486
 
480
- return web.json_response({"status": "result_accepted_failure"}, status=200)
487
+ return json_response({"status": "result_accepted_failure"}, status=200)
481
488
 
482
489
  if result_status == "cancelled":
483
490
  logging.info(f"Task {task_id} for job {job_id} was cancelled by worker.")
@@ -490,7 +497,7 @@ class OrchestratorEngine:
490
497
  job_state["status"] = "running" # It's running the cancellation handler now
491
498
  await self.storage.save_job_state(job_id, job_state)
492
499
  await self.storage.enqueue_job(job_id)
493
- return web.json_response({"status": "result_accepted_cancelled"}, status=200)
500
+ return json_response({"status": "result_accepted_cancelled"}, status=200)
494
501
 
495
502
  transitions = job_state.get("current_task_transitions", {})
496
503
  if next_state := transitions.get(result_status):
@@ -512,7 +519,7 @@ class OrchestratorEngine:
512
519
  job_state["error_message"] = f"Worker returned unhandled status: {result_status}"
513
520
  await self.storage.save_job_state(job_id, job_state)
514
521
 
515
- return web.json_response({"status": "result_accepted_success"}, status=200)
522
+ return json_response({"status": "result_accepted_success"}, status=200)
516
523
 
517
524
  async def _handle_task_failure(self, job_state: dict, task_id: str, error_message: str | None):
518
525
  import logging
@@ -553,61 +560,60 @@ class OrchestratorEngine:
553
560
  async def _human_approval_webhook_handler(self, request: web.Request) -> web.Response:
554
561
  job_id = request.match_info.get("job_id")
555
562
  if not job_id:
556
- return web.json_response({"error": "job_id is required in path"}, status=400)
563
+ return json_response({"error": "job_id is required in path"}, status=400)
557
564
  try:
558
- data = await request.json()
565
+ data = await request.json(loads=loads)
559
566
  decision = data.get("decision")
560
567
  if not decision:
561
- return web.json_response({"error": "decision is required in body"}, status=400)
568
+ return json_response({"error": "decision is required in body"}, status=400)
562
569
  except Exception:
563
- return web.json_response({"error": "Invalid JSON body"}, status=400)
570
+ return json_response({"error": "Invalid JSON body"}, status=400)
564
571
  job_state = await self.storage.get_job_state(job_id)
565
572
  if not job_state:
566
- return web.json_response({"error": "Job not found"}, status=404)
573
+ return json_response({"error": "Job not found"}, status=404)
567
574
  if job_state.get("status") not in ["waiting_for_worker", "waiting_for_human"]:
568
- return web.json_response({"error": "Job is not in a state that can be approved"}, status=409)
575
+ return json_response({"error": "Job is not in a state that can be approved"}, status=409)
569
576
  transitions = job_state.get("current_task_transitions", {})
570
577
  next_state = transitions.get(decision)
571
578
  if not next_state:
572
- return web.json_response({"error": f"Invalid decision '{decision}' for this job"}, status=400)
579
+ return json_response({"error": f"Invalid decision '{decision}' for this job"}, status=400)
573
580
  job_state["current_state"] = next_state
574
581
  job_state["status"] = "running"
575
582
  await self.storage.save_job_state(job_id, job_state)
576
583
  await self.storage.enqueue_job(job_id)
577
- return web.json_response({"status": "approval_received", "job_id": job_id})
584
+ return json_response({"status": "approval_received", "job_id": job_id})
578
585
 
579
586
  async def _get_quarantined_jobs_handler(self, request: web.Request) -> web.Response:
580
587
  """Returns a list of all job IDs in the quarantine queue."""
581
588
  jobs = await self.storage.get_quarantined_jobs()
582
- return web.json_response(jobs)
589
+ return json_response(jobs)
583
590
 
584
591
  async def _reload_worker_configs_handler(self, request: web.Request) -> web.Response:
585
592
  """Handles the dynamic reloading of worker configurations."""
586
593
  logger.info("Received request to reload worker configurations.")
587
594
  if not self.config.WORKERS_CONFIG_PATH:
588
- return web.json_response(
595
+ return json_response(
589
596
  {"error": "WORKERS_CONFIG_PATH is not set, cannot reload configs."},
590
597
  status=400,
591
598
  )
592
599
 
593
600
  await load_worker_configs_to_redis(self.storage, self.config.WORKERS_CONFIG_PATH)
594
- return web.json_response({"status": "worker_configs_reloaded"})
601
+ return json_response({"status": "worker_configs_reloaded"})
595
602
 
596
603
  async def _flush_db_handler(self, request: web.Request) -> web.Response:
597
604
  logger.warning("Received request to flush the database.")
598
605
  await self.storage.flush_all()
599
606
  await load_client_configs_to_redis(self.storage)
600
- return web.json_response({"status": "db_flushed"}, status=200)
607
+ return json_response({"status": "db_flushed"}, status=200)
601
608
 
602
609
  async def _docs_handler(self, request: web.Request) -> web.Response:
603
- import json
604
610
  from importlib import resources
605
611
 
606
612
  try:
607
613
  content = resources.read_text("avtomatika", "api.html")
608
614
  except FileNotFoundError:
609
615
  logger.error("api.html not found within the avtomatika package.")
610
- return web.json_response({"error": "Documentation file not found on server."}, status=500)
616
+ return json_response({"error": "Documentation file not found on server."}, status=500)
611
617
 
612
618
  # Generate dynamic documentation for registered blueprints
613
619
  blueprint_endpoints = []
@@ -639,7 +645,7 @@ class OrchestratorEngine:
639
645
 
640
646
  # Inject dynamic endpoints into the apiData structure in the HTML
641
647
  if blueprint_endpoints:
642
- endpoints_json = json.dumps(blueprint_endpoints, indent=2)
648
+ endpoints_json = dumps(blueprint_endpoints, option=OPT_INDENT_2).decode("utf-8")
643
649
  # We insert the new endpoints at the beginning of the 'Protected API' group
644
650
  marker = "group: 'Protected API',\n endpoints: ["
645
651
  content = content.replace(marker, f"{marker}\n{endpoints_json.strip('[]')},")
@@ -661,7 +667,7 @@ class OrchestratorEngine:
661
667
  api_middlewares = [auth_middleware, quota_middleware]
662
668
 
663
669
  protected_app = web.Application(middlewares=api_middlewares)
664
- versioned_apps: Dict[str, web.Application] = {}
670
+ versioned_apps: dict[str, web.Application] = {}
665
671
  has_unversioned_routes = False
666
672
 
667
673
  for bp in self.blueprints.values():
@@ -739,14 +745,14 @@ class OrchestratorEngine:
739
745
  async def _handle_get_next_task(self, request: web.Request) -> web.Response:
740
746
  worker_id = request.match_info.get("worker_id")
741
747
  if not worker_id:
742
- return web.json_response({"error": "worker_id is required in path"}, status=400)
748
+ return json_response({"error": "worker_id is required in path"}, status=400)
743
749
 
744
750
  logger.debug(f"Worker {worker_id} is requesting a new task.")
745
751
  task = await self.storage.dequeue_task_for_worker(worker_id, self.config.WORKER_POLL_TIMEOUT_SECONDS)
746
752
 
747
753
  if task:
748
754
  logger.info(f"Sending task {task.get('task_id')} to worker {worker_id}")
749
- return web.json_response(task, status=200)
755
+ return json_response(task, status=200)
750
756
  logger.debug(f"No tasks for worker {worker_id}, responding 204.")
751
757
  return web.Response(status=204)
752
758
 
@@ -759,7 +765,7 @@ class OrchestratorEngine:
759
765
  """
760
766
  worker_id = request.match_info.get("worker_id")
761
767
  if not worker_id:
762
- return web.json_response({"error": "worker_id is required in path"}, status=400)
768
+ return json_response({"error": "worker_id is required in path"}, status=400)
763
769
 
764
770
  ttl = self.config.WORKER_HEALTH_CHECK_INTERVAL_SECONDS * 2
765
771
  update_data = None
@@ -767,11 +773,8 @@ class OrchestratorEngine:
767
773
  # Check for body content without consuming it if it's not JSON
768
774
  if request.can_read_body:
769
775
  try:
770
- update_data = await request.json()
776
+ update_data = await request.json(loads=loads)
771
777
  except Exception:
772
- # This can happen if the body is present but not valid JSON.
773
- # We can treat it as a lightweight heartbeat or return an error.
774
- # For robustness, let's treat it as a lightweight ping but log a warning.
775
778
  logger.warning(
776
779
  f"Received PATCH from worker {worker_id} with non-JSON body. Treating as TTL-only heartbeat."
777
780
  )
@@ -780,7 +783,7 @@ class OrchestratorEngine:
780
783
  # Full update path
781
784
  updated_worker = await self.storage.update_worker_status(worker_id, update_data, ttl)
782
785
  if not updated_worker:
783
- return web.json_response({"error": "Worker not found"}, status=404)
786
+ return json_response({"error": "Worker not found"}, status=404)
784
787
 
785
788
  await self.history_storage.log_worker_event(
786
789
  {
@@ -789,25 +792,25 @@ class OrchestratorEngine:
789
792
  "worker_info_snapshot": updated_worker,
790
793
  },
791
794
  )
792
- return web.json_response(updated_worker, status=200)
795
+ return json_response(updated_worker, status=200)
793
796
  else:
794
797
  # Lightweight TTL-only heartbeat path
795
798
  refreshed = await self.storage.refresh_worker_ttl(worker_id, ttl)
796
799
  if not refreshed:
797
- return web.json_response({"error": "Worker not found"}, status=404)
798
- return web.json_response({"status": "ttl_refreshed"})
800
+ return json_response({"error": "Worker not found"}, status=404)
801
+ return json_response({"status": "ttl_refreshed"})
799
802
 
800
803
  async def _register_worker_handler(self, request: web.Request) -> web.Response:
801
804
  # The worker_registration_data is attached by the auth middleware
802
805
  # to avoid reading the request body twice.
803
806
  worker_data = request.get("worker_registration_data")
804
807
  if not worker_data:
805
- return web.json_response({"error": "Worker data not found in request"}, status=500)
808
+ return json_response({"error": "Worker data not found in request"}, status=500)
806
809
 
807
810
  worker_id = worker_data.get("worker_id")
808
811
  # This check is redundant if the middleware works, but good for safety
809
812
  if not worker_id:
810
- return web.json_response({"error": "Missing required field: worker_id"}, status=400)
813
+ return json_response({"error": "Missing required field: worker_id"}, status=400)
811
814
 
812
815
  ttl = self.config.WORKER_HEALTH_CHECK_INTERVAL_SECONDS * 2
813
816
  await self.storage.register_worker(worker_id, worker_data, ttl)
@@ -823,7 +826,7 @@ class OrchestratorEngine:
823
826
  "worker_info_snapshot": worker_data,
824
827
  },
825
828
  )
826
- return web.json_response({"status": "registered"}, status=200)
829
+ return json_response({"status": "registered"}, status=200)
827
830
 
828
831
  def run(self):
829
832
  self.setup()