supervaizer 0.9.7__py3-none-any.whl → 0.10.0__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.
Files changed (58) hide show
  1. supervaizer/__init__.py +11 -2
  2. supervaizer/__version__.py +1 -1
  3. supervaizer/account.py +4 -0
  4. supervaizer/account_service.py +7 -1
  5. supervaizer/admin/routes.py +46 -7
  6. supervaizer/admin/static/js/job-start-form.js +373 -0
  7. supervaizer/admin/templates/agents.html +74 -0
  8. supervaizer/admin/templates/agents_grid.html +5 -3
  9. supervaizer/admin/templates/job_start_test.html +109 -0
  10. supervaizer/admin/templates/navigation.html +11 -1
  11. supervaizer/admin/templates/supervaize_instructions.html +212 -0
  12. supervaizer/agent.py +165 -25
  13. supervaizer/case.py +46 -14
  14. supervaizer/cli.py +248 -8
  15. supervaizer/common.py +45 -4
  16. supervaizer/deploy/__init__.py +16 -0
  17. supervaizer/deploy/cli.py +296 -0
  18. supervaizer/deploy/commands/__init__.py +9 -0
  19. supervaizer/deploy/commands/clean.py +294 -0
  20. supervaizer/deploy/commands/down.py +119 -0
  21. supervaizer/deploy/commands/local.py +460 -0
  22. supervaizer/deploy/commands/plan.py +167 -0
  23. supervaizer/deploy/commands/status.py +169 -0
  24. supervaizer/deploy/commands/up.py +281 -0
  25. supervaizer/deploy/docker.py +370 -0
  26. supervaizer/deploy/driver_factory.py +42 -0
  27. supervaizer/deploy/drivers/__init__.py +39 -0
  28. supervaizer/deploy/drivers/aws_app_runner.py +607 -0
  29. supervaizer/deploy/drivers/base.py +196 -0
  30. supervaizer/deploy/drivers/cloud_run.py +570 -0
  31. supervaizer/deploy/drivers/do_app_platform.py +504 -0
  32. supervaizer/deploy/health.py +404 -0
  33. supervaizer/deploy/state.py +210 -0
  34. supervaizer/deploy/templates/Dockerfile.template +44 -0
  35. supervaizer/deploy/templates/debug_env.py +69 -0
  36. supervaizer/deploy/templates/docker-compose.yml.template +37 -0
  37. supervaizer/deploy/templates/dockerignore.template +66 -0
  38. supervaizer/deploy/templates/entrypoint.sh +20 -0
  39. supervaizer/deploy/utils.py +41 -0
  40. supervaizer/examples/{controller-template.py → controller_template.py} +5 -4
  41. supervaizer/job.py +18 -5
  42. supervaizer/job_service.py +6 -5
  43. supervaizer/parameter.py +61 -1
  44. supervaizer/protocol/__init__.py +2 -2
  45. supervaizer/protocol/a2a/routes.py +1 -1
  46. supervaizer/routes.py +262 -12
  47. supervaizer/server.py +5 -11
  48. supervaizer/utils/__init__.py +16 -0
  49. supervaizer/utils/version_check.py +56 -0
  50. {supervaizer-0.9.7.dist-info → supervaizer-0.10.0.dist-info}/METADATA +105 -34
  51. supervaizer-0.10.0.dist-info/RECORD +76 -0
  52. {supervaizer-0.9.7.dist-info → supervaizer-0.10.0.dist-info}/WHEEL +1 -1
  53. supervaizer/protocol/acp/__init__.py +0 -21
  54. supervaizer/protocol/acp/model.py +0 -198
  55. supervaizer/protocol/acp/routes.py +0 -74
  56. supervaizer-0.9.7.dist-info/RECORD +0 -50
  57. {supervaizer-0.9.7.dist-info → supervaizer-0.10.0.dist-info}/entry_points.txt +0 -0
  58. {supervaizer-0.9.7.dist-info → supervaizer-0.10.0.dist-info}/licenses/LICENSE.md +0 -0
supervaizer/routes.py CHANGED
@@ -6,6 +6,7 @@
6
6
 
7
7
  import traceback
8
8
  from functools import wraps
9
+ from pathlib import Path
9
10
  from typing import (
10
11
  TYPE_CHECKING,
11
12
  Any,
@@ -26,11 +27,12 @@ from fastapi import (
26
27
  Depends,
27
28
  HTTPException,
28
29
  Query,
30
+ Request,
29
31
  Security,
30
32
  status as http_status,
31
33
  )
32
- from fastapi.responses import JSONResponse
33
- from rich import inspect
34
+ from fastapi.responses import FileResponse, HTMLResponse, JSONResponse, Response
35
+ from fastapi.templating import Jinja2Templates
34
36
 
35
37
  from supervaizer.agent import (
36
38
  Agent,
@@ -51,8 +53,6 @@ if TYPE_CHECKING:
51
53
 
52
54
  T = TypeVar("T")
53
55
 
54
- insp = inspect
55
-
56
56
 
57
57
  class CaseUpdateRequest(SvBaseModel):
58
58
  """Request model for updating a case with answer to a question."""
@@ -259,10 +259,13 @@ def create_default_routes(server: "Server") -> APIRouter:
259
259
  )
260
260
 
261
261
  # Update the case with the answer
262
- case.update(update)
262
+ # case.update(update) - Redundant, receive_human_input calls update()
263
263
 
264
264
  # Transition the case from AWAITING to IN_PROGRESS
265
- case.receive_human_input()
265
+ case.receive_human_input(update)
266
+
267
+ # TODO CALL CUSTOM HOOKS HERE - AS DEFINED IN THE AGENT CONFIGURATION
268
+ # TODO REDEFINE AGENT TO ADD CUSTOM HOOKS HERE
266
269
 
267
270
  log.info(
268
271
  f"[Case update] Job {job_id}, Case {case_id} - Answer processed successfully"
@@ -383,6 +386,238 @@ def create_agent_route(server: "Server", agent: Agent) -> APIRouter:
383
386
  **agent.registration_info,
384
387
  )
385
388
 
389
+ @router.get(
390
+ f"/{agent.instructions_path}",
391
+ summary=f"Get supervaize instructions page for agent {agent.name}",
392
+ description="HTML page displaying agent registration information and instructions",
393
+ response_class=HTMLResponse,
394
+ tags=tags,
395
+ )
396
+ @handle_route_errors()
397
+ async def supervaize_instructions(
398
+ request: Request, agent: Agent = Depends(get_agent)
399
+ ) -> Response:
400
+ """Serve the supervaize instructions HTML page for this agent."""
401
+ log.info(
402
+ f"📥 GET /{agent.instructions_path} [Supervaize Instructions] for agent{agent.name}"
403
+ )
404
+
405
+ registration_info = agent.registration_info
406
+
407
+ # Convert instructions_path string to Path object
408
+ instructions_path = Path(agent.instructions_path)
409
+
410
+ # Check if file exists - if not, return empty HTML
411
+ if not instructions_path.exists() or not instructions_path.is_file():
412
+ return HTMLResponse(content="", status_code=200)
413
+
414
+ # Serve the file (check if it's a Jinja2 template or static HTML)
415
+ with open(instructions_path, "r", encoding="utf-8") as f:
416
+ content = f.read()
417
+ # Simple check: if it contains Jinja2 syntax, render as template
418
+ if "{{" in content or "{%" in content:
419
+ # Render as Jinja2 template
420
+ custom_templates = Jinja2Templates(
421
+ directory=str(instructions_path.parent)
422
+ )
423
+ return custom_templates.TemplateResponse(
424
+ instructions_path.name,
425
+ {
426
+ "request": request,
427
+ "registration_info": registration_info,
428
+ },
429
+ )
430
+ else:
431
+ # Serve as static HTML file
432
+ return FileResponse(
433
+ str(instructions_path),
434
+ media_type="text/html",
435
+ )
436
+
437
+ @router.post(
438
+ "/validate-agent-parameters",
439
+ summary=f"Validate agent parameters for agent: {agent.name}",
440
+ description="Validate agent configuration parameters (secrets, API keys, etc.) before starting a job",
441
+ response_model=Dict[str, Any],
442
+ responses={
443
+ http_status.HTTP_200_OK: {"model": Dict[str, Any]},
444
+ http_status.HTTP_400_BAD_REQUEST: {"model": Dict[str, Any]},
445
+ http_status.HTTP_500_INTERNAL_SERVER_ERROR: {"model": ErrorResponse},
446
+ },
447
+ dependencies=[Security(server.verify_api_key)],
448
+ )
449
+ @handle_route_errors()
450
+ async def validate_agent_parameters(
451
+ body_params: Any = Body(...),
452
+ agent: Agent = Depends(get_agent),
453
+ ) -> Dict[str, Any]:
454
+ """Validate agent parameters for this agent"""
455
+ log.info(
456
+ f"📥 POST /validate-agent-parameters [Validate agent parameters] {agent.name}"
457
+ )
458
+
459
+ if not agent.parameters_setup:
460
+ result = {
461
+ "valid": True,
462
+ "message": "Agent has no parameter setup defined",
463
+ "errors": [],
464
+ "invalid_parameters": {},
465
+ }
466
+ log.info(f"📤 Agent {agent.name}: No parameter setup defined → {result}")
467
+ return result
468
+
469
+ if body_params is None:
470
+ body_params = {}
471
+
472
+ encrypted_agent_parameters = body_params.get("encrypted_agent_parameters")
473
+
474
+ agent_parameters: Dict[str, Any] = {}
475
+ if encrypted_agent_parameters:
476
+ # Basic debug trace
477
+ log.info(
478
+ f"📥 Received encrypted_agent_parameters, length: {len(encrypted_agent_parameters)}"
479
+ )
480
+
481
+ try:
482
+ import json
483
+
484
+ from supervaizer.common import decrypt_value
485
+
486
+ agent_parameters_str = decrypt_value(
487
+ encrypted_agent_parameters, server.private_key
488
+ )
489
+ agent_parameters = (
490
+ json.loads(agent_parameters_str) if agent_parameters_str else {}
491
+ )
492
+
493
+ # Debug: Log the parsed data type and structure
494
+ log.info(f"🔍 Parsed agent_parameters type: {type(agent_parameters)}")
495
+ if isinstance(agent_parameters, list):
496
+ log.info(
497
+ f"🔍 Converting list to dict with {len(agent_parameters)} items"
498
+ )
499
+ # Convert list to dict if needed (common when frontend sends array)
500
+ agent_parameters = {
501
+ f"param_{i}": param for i, param in enumerate(agent_parameters)
502
+ }
503
+ elif isinstance(agent_parameters, dict):
504
+ log.info(
505
+ f"🔍 Agent parameters keys: {list(agent_parameters.keys())}"
506
+ )
507
+ else:
508
+ log.warning(
509
+ f"🔍 Unexpected type: {type(agent_parameters)}, converting to empty dict"
510
+ )
511
+ agent_parameters = {}
512
+
513
+ except Exception as e:
514
+ log.error(f"❌ Decryption failed: {type(e).__name__}: {str(e)}")
515
+ result = {
516
+ "valid": False,
517
+ "message": f"Failed to decrypt agent parameters: {str(e)}",
518
+ "errors": [f"Decryption failed: {str(e)}"],
519
+ "invalid_parameters": {
520
+ "encrypted_agent_parameters": f"Decryption failed: {str(e)}"
521
+ },
522
+ }
523
+ log.info(f"📤 Agent {agent.name}: Decryption failed → {result}")
524
+ return result
525
+
526
+ # Log the incoming request details
527
+ log.info(
528
+ f"🔍 Agent {agent.name}: Incoming request - encrypted_params: {bool(encrypted_agent_parameters)}, parsed_params: {agent_parameters}"
529
+ )
530
+
531
+ # Validate agent parameters
532
+ validation_result = agent.parameters_setup.validate_parameters(agent_parameters)
533
+
534
+ result = {
535
+ "valid": validation_result["valid"],
536
+ "message": "Agent parameters validated successfully"
537
+ if validation_result["valid"]
538
+ else "Agent parameter validation failed",
539
+ "errors": validation_result["errors"],
540
+ "invalid_parameters": validation_result["invalid_parameters"],
541
+ }
542
+
543
+ log.info(f"📤 Agent {agent.name}: Validation result → {result}")
544
+ return result
545
+
546
+ @router.post(
547
+ "/validate-method-fields",
548
+ summary=f"Validate method fields for agent: {agent.name}",
549
+ description="Validate job input fields against the method's field definitions before starting a job",
550
+ response_model=Dict[str, Any],
551
+ responses={
552
+ http_status.HTTP_200_OK: {"model": Dict[str, Any]},
553
+ http_status.HTTP_400_BAD_REQUEST: {"model": Dict[str, Any]},
554
+ http_status.HTTP_500_INTERNAL_SERVER_ERROR: {"model": ErrorResponse},
555
+ },
556
+ dependencies=[Security(server.verify_api_key)],
557
+ )
558
+ @handle_route_errors()
559
+ async def validate_method_fields(
560
+ body_params: Any = Body(...),
561
+ agent: Agent = Depends(get_agent),
562
+ ) -> Dict[str, Any]:
563
+ """Validate method fields for this agent"""
564
+ log.info(
565
+ f"📥 POST /validate-method-fields [Validate method fields] {agent.name}"
566
+ )
567
+
568
+ if body_params is None:
569
+ body_params = {}
570
+
571
+ method_name = body_params.get("method_name", "job_start")
572
+ job_fields = body_params.get("job_fields", {})
573
+
574
+ # Log the incoming request details
575
+ log.info(
576
+ f"🔍 Agent {agent.name}: Incoming request - method: {method_name}, fields: {job_fields}"
577
+ )
578
+
579
+ # Get the method to validate against
580
+ if not agent.methods:
581
+ result = {
582
+ "valid": False,
583
+ "message": "Agent has no methods defined",
584
+ "errors": ["Agent has no methods defined"],
585
+ "invalid_fields": {},
586
+ }
587
+ log.info(f"📤 Agent {agent.name}: No methods defined → {result}")
588
+ return result
589
+
590
+ if method_name == "job_start":
591
+ method = agent.methods.job_start
592
+ elif agent.methods.custom and method_name in agent.methods.custom:
593
+ method = agent.methods.custom[method_name]
594
+ else:
595
+ result = {
596
+ "valid": False,
597
+ "message": f"Method '{method_name}' not found",
598
+ "errors": [f"Method '{method_name}' not found"],
599
+ "invalid_fields": {},
600
+ }
601
+ log.info(
602
+ f"📤 Agent {agent.name}: Method '{method_name}' not found → {result}"
603
+ )
604
+ return result
605
+
606
+ # Validate method fields
607
+ validation_result = method.validate_method_fields(job_fields)
608
+
609
+ result = {
610
+ "valid": validation_result["valid"],
611
+ "message": validation_result["message"],
612
+ "errors": validation_result["errors"],
613
+ "invalid_fields": validation_result["invalid_fields"],
614
+ }
615
+
616
+ log.info(
617
+ f"📤 Agent {agent.name}: Method '{method_name}' validation result → {result}"
618
+ )
619
+ return result
620
+
386
621
  if not agent.methods:
387
622
  raise ValueError(f"Agent {agent.name} has no methods defined")
388
623
 
@@ -400,6 +635,7 @@ def create_agent_route(server: "Server", agent: Agent) -> APIRouter:
400
635
  description=f"{agent.methods.job_start.description}",
401
636
  responses={
402
637
  http_status.HTTP_202_ACCEPTED: {"model": Job},
638
+ http_status.HTTP_400_BAD_REQUEST: {"model": Dict[str, Any]},
403
639
  http_status.HTTP_409_CONFLICT: {"model": ErrorResponse},
404
640
  http_status.HTTP_500_INTERNAL_SERVER_ERROR: {"model": ErrorResponse},
405
641
  },
@@ -415,13 +651,19 @@ def create_agent_route(server: "Server", agent: Agent) -> APIRouter:
415
651
  ) -> Union[Job, JSONResponse]:
416
652
  """Start a new job for this agent"""
417
653
  log.info(f"📥 POST /jobs [Start job] {agent.name} with params {body_params}")
418
- sv_context: JobContext = body_params.job_context
419
- job_fields = body_params.job_fields.to_dict()
654
+
655
+ if body_params is None:
656
+ body_params = {}
657
+
658
+ job_context_data = body_params.get("job_context")
659
+ if job_context_data is None:
660
+ raise ValueError("job_context is required")
661
+
662
+ sv_context: JobContext = JobContext(**job_context_data)
663
+ job_fields = body_params.get("job_fields", {})
420
664
 
421
665
  # Get job encrypted parameters if available
422
- encrypted_agent_parameters = getattr(
423
- body_params, "encrypted_agent_parameters", None
424
- )
666
+ encrypted_agent_parameters = body_params.get("encrypted_agent_parameters")
425
667
 
426
668
  # Delegate job creation and scheduling to the service
427
669
  new_job = await service_job_start(
@@ -526,7 +768,9 @@ def create_agent_route(server: "Server", agent: Agent) -> APIRouter:
526
768
  agent: Agent = Depends(get_agent),
527
769
  ) -> AgentResponse:
528
770
  log.info(f"📥 POST /stop [Stop agent] {agent.name} with params {params}")
529
- result = agent.job_stop(params.get("job_context", {}))
771
+ # Pass job_context as 'context' parameter to match agent method expectations
772
+ job_context = params.get("job_context", {})
773
+ result = agent.job_stop({"context": job_context})
530
774
  res_info = result.registration_info if result else {}
531
775
  return AgentResponse(
532
776
  name=agent.name,
@@ -623,6 +867,7 @@ def create_agent_custom_routes(server: "Server", agent: Agent) -> APIRouter:
623
867
  response_model=JobResponse,
624
868
  responses={
625
869
  http_status.HTTP_202_ACCEPTED: {"model": JobResponse},
870
+ http_status.HTTP_400_BAD_REQUEST: {"model": Dict[str, Any]},
626
871
  http_status.HTTP_405_METHOD_NOT_ALLOWED: {"model": ErrorResponse},
627
872
  },
628
873
  dependencies=[Security(server.verify_api_key)],
@@ -637,6 +882,11 @@ def create_agent_custom_routes(server: "Server", agent: Agent) -> APIRouter:
637
882
  log.info(
638
883
  f"📥 POST /custom/{method_name} [custom job] {agent.name} with params {body_params}"
639
884
  )
885
+ log.info(f"body_params: {body_params}")
886
+
887
+ if body_params is None:
888
+ raise ValueError("body_params cannot be None")
889
+
640
890
  sv_context: JobContext = body_params.job_context
641
891
  job_fields = body_params.job_fields.to_dict()
642
892
 
supervaizer/server.py CHANGED
@@ -38,7 +38,6 @@ from supervaizer.common import (
38
38
  )
39
39
  from supervaizer.instructions import display_instructions
40
40
  from supervaizer.protocol.a2a import create_routes as create_a2a_routes
41
- from supervaizer.protocol.acp import create_routes as create_acp_routes
42
41
  from supervaizer.routes import (
43
42
  create_agents_routes,
44
43
  create_default_routes,
@@ -82,6 +81,9 @@ def save_server_info_to_storage(server_instance: "Server") -> None:
82
81
  "name": agent.name,
83
82
  "description": agent.description,
84
83
  "version": agent.version,
84
+ "api_path": agent.path,
85
+ "slug": agent.slug,
86
+ "instructions_path": agent.instructions_path,
85
87
  }
86
88
  )
87
89
 
@@ -166,9 +168,6 @@ class ServerAbstract(SvBaseModel):
166
168
  a2a_endpoints: bool = Field(
167
169
  default=True, description="Whether to enable A2A endpoints"
168
170
  )
169
- acp_endpoints: bool = Field(
170
- default=True, description="Whether to enable ACP endpoints"
171
- )
172
171
  private_key: RSAPrivateKey = Field(
173
172
  description="RSA private key for secret parameters encryption - Used in server-to-agent communication - Not needed by user"
174
173
  )
@@ -206,7 +205,6 @@ class ServerAbstract(SvBaseModel):
206
205
  "debug": False,
207
206
  "reload": False,
208
207
  "a2a_endpoints": True,
209
- "acp_endpoints": True,
210
208
  },
211
209
  ]
212
210
  },
@@ -237,7 +235,6 @@ class Server(ServerAbstract):
237
235
  agents: List[Agent],
238
236
  supervisor_account: Optional[Account] = None,
239
237
  a2a_endpoints: bool = True,
240
- acp_endpoints: bool = True,
241
238
  admin_interface: bool = True,
242
239
  scheme: str = "http",
243
240
  environment: str = os.getenv("SUPERVAIZER_ENVIRONMENT", "dev"),
@@ -259,7 +256,6 @@ class Server(ServerAbstract):
259
256
  agents: List of agents to register with the server
260
257
  supervisor_account: Account of the supervisor
261
258
  a2a_endpoints: Whether to enable A2A endpoints
262
- acp_endpoints: Whether to enable ACP endpoints
263
259
  admin_interface: Whether to enable admin interface
264
260
  scheme: URL scheme (http or https)
265
261
  environment: Environment name (e.g., dev, staging, prod)
@@ -350,7 +346,6 @@ class Server(ServerAbstract):
350
346
  reload=reload,
351
347
  supervisor_account=supervisor_account,
352
348
  a2a_endpoints=a2a_endpoints,
353
- acp_endpoints=acp_endpoints,
354
349
  private_key=private_key,
355
350
  public_key=public_key,
356
351
  public_url=public_url,
@@ -371,9 +366,6 @@ class Server(ServerAbstract):
371
366
  if self.a2a_endpoints:
372
367
  log.info("[Server launch] 📢 Deploy A2A routes ")
373
368
  self.app.include_router(create_a2a_routes(self))
374
- if self.acp_endpoints:
375
- log.info("[Server launch] 📢 Deploy ACP routes")
376
- self.app.include_router(create_acp_routes(self))
377
369
 
378
370
  # Deploy admin routes if API key is available
379
371
  if self.api_key and admin_interface:
@@ -519,6 +511,8 @@ class Server(ServerAbstract):
519
511
  server_registration_result: ApiResult = (
520
512
  self.supervisor_account.register_server(server=self)
521
513
  )
514
+ # log.debug(f"[Server launch] Server registration result: {server_registration_result}")
515
+ # inspect(server_registration_result)
522
516
  assert isinstance(
523
517
  server_registration_result, ApiSuccess
524
518
  ) # If ApiError, exception should have been raised before
@@ -0,0 +1,16 @@
1
+ # Copyright (c) 2024-2025 Alain Prasquier - Supervaize.com. All rights reserved.
2
+ #
3
+ # This Source Code Form is subject to the terms of the Mozilla Public License, v. 2.0.
4
+ # If a copy of the MPL was not distributed with this file, you can obtain one at
5
+ # https://mozilla.org/MPL/2.0/.
6
+
7
+
8
+ from supervaizer.utils.version_check import (
9
+ check_is_latest_version,
10
+ get_latest_version,
11
+ )
12
+
13
+ __all__ = [
14
+ "check_is_latest_version",
15
+ "get_latest_version",
16
+ ]
@@ -0,0 +1,56 @@
1
+ # Copyright (c) 2024-2025 Alain Prasquier - Supervaize.com. All rights reserved.
2
+ #
3
+ # This Source Code Form is subject to the terms of the Mozilla Public License, v. 2.0.
4
+ # If a copy of the MPL was not distributed with this file, you can obtain one at
5
+ # https://mozilla.org/MPL/2.0/.
6
+
7
+
8
+ import httpx
9
+
10
+ try:
11
+ from packaging import version
12
+ except ImportError:
13
+ version = None
14
+
15
+ from supervaizer import __version__
16
+
17
+
18
+ async def get_latest_version() -> str | None:
19
+ """
20
+ Retrieve the latest version number of supervaizer from PyPI.
21
+
22
+ Returns:
23
+ The latest version string (e.g., "0.9.8") if successful, None otherwise.
24
+ """
25
+ try:
26
+ async with httpx.AsyncClient(timeout=10.0) as client:
27
+ response = await client.get("https://pypi.org/pypi/supervaizer/json")
28
+ response.raise_for_status()
29
+ data = response.json()
30
+ return data.get("info", {}).get("version")
31
+ except Exception:
32
+ return None
33
+
34
+
35
+ async def check_is_latest_version() -> tuple[bool, str | None]:
36
+ """
37
+ Check if the currently running supervaizer version is the latest available on PyPI.
38
+
39
+ Returns:
40
+ A tuple of (is_latest: bool, latest_version: str | None).
41
+ - is_latest: True if current version is latest or if check failed
42
+ - latest_version: The latest version string if available, None otherwise
43
+ """
44
+ current_version = __version__.VERSION
45
+ latest_version = await get_latest_version()
46
+
47
+ if latest_version is None:
48
+ return True, None
49
+
50
+ # Compare versions using packaging.version for proper semantic version comparison
51
+ if version is not None:
52
+ is_latest = version.parse(current_version) >= version.parse(latest_version)
53
+ else:
54
+ # Fallback to simple string comparison if packaging is not available
55
+ is_latest = current_version >= latest_version
56
+ return is_latest, latest_version