supervaizer 0.10.5__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 (76) hide show
  1. supervaizer/__init__.py +97 -0
  2. supervaizer/__version__.py +10 -0
  3. supervaizer/account.py +308 -0
  4. supervaizer/account_service.py +93 -0
  5. supervaizer/admin/routes.py +1293 -0
  6. supervaizer/admin/static/js/job-start-form.js +373 -0
  7. supervaizer/admin/templates/agent_detail.html +145 -0
  8. supervaizer/admin/templates/agents.html +249 -0
  9. supervaizer/admin/templates/agents_grid.html +82 -0
  10. supervaizer/admin/templates/base.html +233 -0
  11. supervaizer/admin/templates/case_detail.html +230 -0
  12. supervaizer/admin/templates/cases_list.html +182 -0
  13. supervaizer/admin/templates/cases_table.html +134 -0
  14. supervaizer/admin/templates/console.html +389 -0
  15. supervaizer/admin/templates/dashboard.html +153 -0
  16. supervaizer/admin/templates/job_detail.html +192 -0
  17. supervaizer/admin/templates/job_start_test.html +109 -0
  18. supervaizer/admin/templates/jobs_list.html +180 -0
  19. supervaizer/admin/templates/jobs_table.html +122 -0
  20. supervaizer/admin/templates/navigation.html +163 -0
  21. supervaizer/admin/templates/recent_activity.html +81 -0
  22. supervaizer/admin/templates/server.html +105 -0
  23. supervaizer/admin/templates/server_status_cards.html +121 -0
  24. supervaizer/admin/templates/supervaize_instructions.html +212 -0
  25. supervaizer/agent.py +956 -0
  26. supervaizer/case.py +432 -0
  27. supervaizer/cli.py +395 -0
  28. supervaizer/common.py +324 -0
  29. supervaizer/deploy/__init__.py +16 -0
  30. supervaizer/deploy/cli.py +305 -0
  31. supervaizer/deploy/commands/__init__.py +9 -0
  32. supervaizer/deploy/commands/clean.py +294 -0
  33. supervaizer/deploy/commands/down.py +119 -0
  34. supervaizer/deploy/commands/local.py +460 -0
  35. supervaizer/deploy/commands/plan.py +167 -0
  36. supervaizer/deploy/commands/status.py +169 -0
  37. supervaizer/deploy/commands/up.py +281 -0
  38. supervaizer/deploy/docker.py +377 -0
  39. supervaizer/deploy/driver_factory.py +42 -0
  40. supervaizer/deploy/drivers/__init__.py +39 -0
  41. supervaizer/deploy/drivers/aws_app_runner.py +607 -0
  42. supervaizer/deploy/drivers/base.py +196 -0
  43. supervaizer/deploy/drivers/cloud_run.py +570 -0
  44. supervaizer/deploy/drivers/do_app_platform.py +504 -0
  45. supervaizer/deploy/health.py +404 -0
  46. supervaizer/deploy/state.py +210 -0
  47. supervaizer/deploy/templates/Dockerfile.template +44 -0
  48. supervaizer/deploy/templates/debug_env.py +69 -0
  49. supervaizer/deploy/templates/docker-compose.yml.template +37 -0
  50. supervaizer/deploy/templates/dockerignore.template +66 -0
  51. supervaizer/deploy/templates/entrypoint.sh +20 -0
  52. supervaizer/deploy/utils.py +52 -0
  53. supervaizer/event.py +181 -0
  54. supervaizer/examples/controller_template.py +196 -0
  55. supervaizer/instructions.py +145 -0
  56. supervaizer/job.py +392 -0
  57. supervaizer/job_service.py +156 -0
  58. supervaizer/lifecycle.py +417 -0
  59. supervaizer/parameter.py +233 -0
  60. supervaizer/protocol/__init__.py +11 -0
  61. supervaizer/protocol/a2a/__init__.py +21 -0
  62. supervaizer/protocol/a2a/model.py +227 -0
  63. supervaizer/protocol/a2a/routes.py +99 -0
  64. supervaizer/py.typed +1 -0
  65. supervaizer/routes.py +917 -0
  66. supervaizer/server.py +553 -0
  67. supervaizer/server_utils.py +54 -0
  68. supervaizer/storage.py +462 -0
  69. supervaizer/telemetry.py +81 -0
  70. supervaizer/utils/__init__.py +16 -0
  71. supervaizer/utils/version_check.py +56 -0
  72. supervaizer-0.10.5.dist-info/METADATA +317 -0
  73. supervaizer-0.10.5.dist-info/RECORD +76 -0
  74. supervaizer-0.10.5.dist-info/WHEEL +4 -0
  75. supervaizer-0.10.5.dist-info/entry_points.txt +2 -0
  76. supervaizer-0.10.5.dist-info/licenses/LICENSE.md +346 -0
supervaizer/storage.py ADDED
@@ -0,0 +1,462 @@
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
+ # Copyright (c) 2024-2025 Alain Prasquier - Supervaize.com. All rights reserved.
8
+ #
9
+ # This Source Code Form is subject to the terms of the Mozilla Public License, v. 2.0.
10
+ # If a copy of the MPL was not distributed with this file, You can obtain one at
11
+ # https://mozilla.org/MPL/2.0/.
12
+
13
+ import os
14
+ import threading
15
+ from pathlib import Path
16
+ from typing import TYPE_CHECKING, Any, Dict, Generic, List, Optional, TypeVar
17
+
18
+ from tinydb import Query, TinyDB
19
+ from tinydb.storages import MemoryStorage
20
+
21
+ from supervaizer.common import log, singleton
22
+ from supervaizer.lifecycle import WorkflowEntity
23
+
24
+
25
+ class _MemoryStorage(MemoryStorage):
26
+ """MemoryStorage that accepts TinyDB's sort_keys/indent kwargs (ignored)."""
27
+
28
+ def __init__(self, *args: Any, **kwargs: Any) -> None:
29
+ super().__init__(*args)
30
+
31
+
32
+ if TYPE_CHECKING:
33
+ from supervaizer.case import Case
34
+ from supervaizer.job import Job
35
+ from supervaizer.lifecycle import EntityEvents, EntityStatus
36
+
37
+
38
+ T = TypeVar("T", bound=WorkflowEntity)
39
+
40
+ DATA_STORAGE_PATH = os.getenv("DATA_STORAGE_PATH", "./data")
41
+
42
+ # When False (default), use in-memory storage only (e.g. Vercel, serverless).
43
+ # Set SUPERVAIZER_PERSISTENCE=true to persist to file.
44
+ PERSISTENCE_ENABLED = os.getenv("SUPERVAIZER_PERSISTENCE", "false").lower() in (
45
+ "true",
46
+ "1",
47
+ "yes",
48
+ )
49
+
50
+
51
+ @singleton
52
+ class StorageManager:
53
+ """
54
+ Thread-safe TinyDB-based persistence manager for WorkflowEntity instances.
55
+
56
+ Stores entities in separate tables by type, with foreign key relationships
57
+ represented as ID references (Job.case_ids, Case.job_id).
58
+
59
+ When SUPERVAIZER_PERSISTENCE is false (default), uses in-memory storage only.
60
+ """
61
+
62
+ def __init__(self, db_path: Optional[str] = None):
63
+ """
64
+ Initialize the storage manager.
65
+
66
+ Args:
67
+ db_path: Path to the TinyDB JSON file, or None to use env-based
68
+ persistence (file if SUPERVAIZER_PERSISTENCE=true, else memory).
69
+ """
70
+ self._lock = threading.Lock()
71
+ # Explicit file path (e.g. tests) uses file; else file only if persistence enabled
72
+ use_file = (db_path is not None and db_path != ":memory:") or (
73
+ db_path is None and PERSISTENCE_ENABLED
74
+ )
75
+ if use_file:
76
+ path = (
77
+ db_path
78
+ if (db_path and db_path != ":memory:")
79
+ else f"{DATA_STORAGE_PATH}/entities.json"
80
+ )
81
+ self.db_path = Path(path)
82
+ Path(path).parent.mkdir(parents=True, exist_ok=True)
83
+ self._db = TinyDB(path, sort_keys=True, indent=2)
84
+ else:
85
+ # In-memory only (default for Vercel/serverless)
86
+ self.db_path = Path(":memory:")
87
+ self._db = TinyDB(storage=_MemoryStorage, sort_keys=True, indent=2)
88
+
89
+ # log.debug(
90
+ # f"[StorageManager] 🗃️ Local DB initialized at {self.db_path.absolute()}"
91
+ # )
92
+
93
+ def save_object(self, type: str, obj: Dict[str, Any]) -> None:
94
+ """
95
+ Save an object to the appropriate table.
96
+
97
+ Args:
98
+ type: The object type (class name)
99
+ obj: Dictionary representation of the object
100
+ """
101
+ with self._lock:
102
+ table = self._db.table(type)
103
+ obj_id = obj.get("id")
104
+
105
+ if not obj_id:
106
+ raise ValueError(
107
+ f"[StorageManager] §SSSS01 Object must have an 'id' field: {obj}"
108
+ )
109
+
110
+ # Use upsert to handle both new and existing objects
111
+ query = Query()
112
+ table.upsert(obj, query.id == obj_id)
113
+
114
+ # log.debug(f"Saved object with ID: {type} {obj_id} - {obj}")
115
+
116
+ def get_objects(self, type: str) -> List[Dict[str, Any]]:
117
+ """
118
+ Get all objects of a specific type.
119
+
120
+ Args:
121
+ type: The object type (class name)
122
+
123
+ Returns:
124
+ List of object dictionaries
125
+ """
126
+ with self._lock:
127
+ table = self._db.table(type)
128
+ documents = table.all()
129
+ return [dict(doc) for doc in documents]
130
+
131
+ def get_object_by_id(self, type: str, obj_id: str) -> Optional[Dict[str, Any]]:
132
+ """
133
+ Get a specific object by its ID.
134
+
135
+ Args:
136
+ type: The object type (class name)
137
+ obj_id: The object ID
138
+
139
+ Returns:
140
+ Object dictionary if found, None otherwise
141
+ """
142
+ with self._lock:
143
+ table = self._db.table(type)
144
+ query = Query()
145
+ result = table.search(query.id == obj_id)
146
+ return result[0] if result else None
147
+
148
+ def delete_object(self, type: str, obj_id: str) -> bool:
149
+ """
150
+ Delete an object by its ID.
151
+
152
+ Args:
153
+ type: The object type (class name)
154
+ obj_id: The object ID
155
+
156
+ Returns:
157
+ True if object was deleted, False if not found
158
+ """
159
+ with self._lock:
160
+ table = self._db.table(type)
161
+ query = Query()
162
+ deleted_count = len(table.remove(query.id == obj_id))
163
+
164
+ if deleted_count > 0:
165
+ log.debug(f"Deleted {type} object with ID: {obj_id}")
166
+ return True
167
+ return False
168
+
169
+ def reset_storage(self) -> None:
170
+ """
171
+ Reset storage by clearing all tables but preserving the database file.
172
+ """
173
+ with self._lock:
174
+ # Clear all tables
175
+ for table_name in self._db.tables():
176
+ self._db.drop_table(table_name)
177
+
178
+ log.info("Storage reset - all tables cleared")
179
+
180
+ def get_cases_for_job(self, job_id: str) -> List[Dict[str, Any]]:
181
+ """
182
+ Helper method to get all cases for a specific job.
183
+
184
+ Args:
185
+ job_id: The job ID
186
+
187
+ Returns:
188
+ List of case dictionaries
189
+ """
190
+ with self._lock:
191
+ table = self._db.table("Case")
192
+ query = Query()
193
+ documents = table.search(query.job_id == job_id)
194
+ return [dict(doc) for doc in documents]
195
+
196
+ def close(self) -> None:
197
+ """Close the database connection."""
198
+ with self._lock:
199
+ if hasattr(self, "_db") and self._db is not None:
200
+ try:
201
+ if hasattr(self._db, "close"):
202
+ self._db.close()
203
+ log.info("StorageManager database closed")
204
+ except ValueError as e:
205
+ # Handle the case where the file is already closed
206
+ if "I/O operation on closed file" in str(e):
207
+ log.debug("Database file already closed")
208
+ else:
209
+ raise
210
+
211
+
212
+ class EntityRepository(Generic[T]):
213
+ """
214
+ Generic repository for WorkflowEntity types with type-safe operations.
215
+
216
+ Provides higher-level abstraction over StorageManager for specific entity types.
217
+ """
218
+
219
+ def __init__(
220
+ self, entity_class: type[T], storage_manager: Optional[StorageManager] = None
221
+ ):
222
+ """
223
+ Initialize repository for a specific entity type.
224
+
225
+ Args:
226
+ entity_class: The entity class this repository manages
227
+ storage_manager: Optional storage manager instance
228
+ """
229
+ self.entity_class = entity_class
230
+ self.type_name = entity_class.__name__
231
+ self.storage = storage_manager or StorageManager()
232
+
233
+ def get_by_id(self, entity_id: str) -> Optional[T]:
234
+ """
235
+ Get an entity by its ID.
236
+
237
+ Args:
238
+ entity_id: The entity ID
239
+
240
+ Returns:
241
+ Entity instance if found, None otherwise
242
+ """
243
+ data = self.storage.get_object_by_id(self.type_name, entity_id)
244
+ if data:
245
+ return self._from_dict(data)
246
+ return None
247
+
248
+ def save(self, entity: T) -> None:
249
+ """
250
+ Save an entity to storage.
251
+
252
+ Args:
253
+ entity: The entity to save
254
+ """
255
+ data = self._to_dict(entity)
256
+ self.storage.save_object(self.type_name, data)
257
+
258
+ def get_all(self) -> List[T]:
259
+ """
260
+ Get all entities of this type.
261
+
262
+ Returns:
263
+ List of entity instances
264
+ """
265
+ data_list = self.storage.get_objects(self.type_name)
266
+ return [self._from_dict(data) for data in data_list]
267
+
268
+ def delete(self, entity_id: str) -> bool:
269
+ """
270
+ Delete an entity by its ID.
271
+
272
+ Args:
273
+ entity_id: The entity ID
274
+
275
+ Returns:
276
+ True if deleted, False if not found
277
+ """
278
+ return self.storage.delete_object(self.type_name, entity_id)
279
+
280
+ def _to_dict(self, entity: T) -> Dict[str, Any]:
281
+ """Convert entity to dictionary using its to_dict property."""
282
+ if hasattr(entity, "to_dict"):
283
+ return dict(entity.to_dict)
284
+ else:
285
+ # Fallback for entities without to_dict
286
+ return {
287
+ field: getattr(entity, field)
288
+ for field in getattr(entity, "__dataclass_fields__", {})
289
+ if hasattr(entity, field)
290
+ }
291
+
292
+ def _from_dict(self, data: Dict[str, Any]) -> T:
293
+ """
294
+ Convert dictionary back to entity instance.
295
+
296
+ Note: This is a simplified implementation. In practice, you might need
297
+ more sophisticated deserialization depending on your entity structure.
298
+ """
299
+ # For entities inheriting from SvBaseModel (Pydantic), use model construction
300
+ if hasattr(self.entity_class, "model_validate"):
301
+ return self.entity_class.model_validate(data) # type: ignore
302
+ else:
303
+ # Fallback for other types
304
+ return self.entity_class(**data)
305
+
306
+
307
+ class PersistentEntityLifecycle:
308
+ """
309
+ Enhanced EntityLifecycle that automatically persists entity state changes.
310
+
311
+ This class wraps the original EntityLifecycle methods to add persistence.
312
+ """
313
+
314
+ @staticmethod
315
+ def transition(
316
+ entity: T, to_status: "EntityStatus", storage: Optional[StorageManager] = None
317
+ ) -> tuple[bool, str]:
318
+ """
319
+ Transition an entity and automatically persist the change.
320
+
321
+ Args:
322
+ entity: The entity to transition
323
+ to_status: Target status
324
+ storage: Optional storage manager instance
325
+
326
+ Returns:
327
+ Tuple of (success, error_message)
328
+ """
329
+ # Import here to avoid circular imports
330
+ from supervaizer.lifecycle import EntityLifecycle
331
+
332
+ # Perform the transition
333
+ success, error = EntityLifecycle.transition(entity, to_status)
334
+
335
+ # If successful, persist the entity
336
+ if success:
337
+ storage_mgr = storage or StorageManager()
338
+ entity_dict = entity.to_dict if hasattr(entity, "to_dict") else vars(entity)
339
+ storage_mgr.save_object(type(entity).__name__, entity_dict)
340
+ log.debug(
341
+ f"[Storage transition] Auto-persisted {type(entity).__name__} {entity.id} after transition to {to_status}"
342
+ )
343
+
344
+ return success, error
345
+
346
+ @staticmethod
347
+ def handle_event(
348
+ entity: T, event: "EntityEvents", storage: Optional[StorageManager] = None
349
+ ) -> tuple[bool, str]:
350
+ """
351
+ Handle an event and automatically persist the change.
352
+
353
+ Args:
354
+ entity: The entity to handle event for
355
+ event: The event to handle
356
+ storage: Optional storage manager instance
357
+
358
+ Returns:
359
+ Tuple of (success, error_message)
360
+ """
361
+ # Import here to avoid circular imports
362
+ from supervaizer.lifecycle import EntityLifecycle
363
+
364
+ # Handle the event
365
+ success, error = EntityLifecycle.handle_event(entity, event)
366
+
367
+ # If successful, persist the entity
368
+ if success:
369
+ storage_mgr = storage or StorageManager()
370
+ entity_dict = entity.to_dict if hasattr(entity, "to_dict") else vars(entity)
371
+ storage_mgr.save_object(type(entity).__name__, entity_dict)
372
+ log.debug(
373
+ f"[Storage handle_event] Auto-persisted {type(entity).__name__} {entity.id} after handling event {event}"
374
+ )
375
+
376
+ return success, error
377
+
378
+
379
+ def create_job_repository() -> "EntityRepository[Job]":
380
+ """Factory function to create a Job repository."""
381
+ from supervaizer.job import Job
382
+
383
+ return EntityRepository(Job)
384
+
385
+
386
+ def create_case_repository() -> "EntityRepository[Case]":
387
+ """Factory function to create a Case repository."""
388
+ from supervaizer.case import Case
389
+
390
+ return EntityRepository(Case)
391
+
392
+
393
+ def load_running_entities_on_startup() -> None:
394
+ """
395
+ Load all running entities from storage and populate registries at startup.
396
+
397
+ This function loads jobs and cases that are in running states:
398
+ - IN_PROGRESS
399
+ - CANCELLING
400
+ - AWAITING
401
+
402
+ This ensures that after a server restart, all running workflows
403
+ continue to be accessible through the in-memory registries.
404
+ """
405
+ from supervaizer.case import Case, Cases
406
+ from supervaizer.job import Job, Jobs
407
+ from supervaizer.lifecycle import EntityStatus
408
+
409
+ storage = StorageManager()
410
+
411
+ # Clear existing registries to start fresh
412
+ Jobs().reset()
413
+ Cases().reset()
414
+
415
+ # Load running jobs
416
+ job_data_list = storage.get_objects("Job")
417
+ loaded_jobs = 0
418
+
419
+ for job_data in job_data_list:
420
+ job_status = job_data.get("status")
421
+ if job_status in [status.value for status in EntityStatus.status_running()]:
422
+ try:
423
+ # Use model_construct to avoid triggering __init__ side effects
424
+ job = Job.model_construct(**job_data)
425
+ # Manually add to registry since we bypassed __init__
426
+ Jobs().add_job(job)
427
+ loaded_jobs += 1
428
+ # log.debug(
429
+ # f"[Startup] Loaded running job: {job.id} (status: {job.status})"
430
+ # )
431
+ except Exception as e:
432
+ log.error(
433
+ f"[Storage] §SSL01 Failed to load job {job_data.get('id', 'unknown')}: {e}"
434
+ )
435
+
436
+ # Load running cases
437
+ case_data_list = storage.get_objects("Case")
438
+ loaded_cases = 0
439
+
440
+ for case_data in case_data_list:
441
+ case_status = case_data.get("status")
442
+ if case_status in [status.value for status in EntityStatus.status_running()]:
443
+ try:
444
+ # Use model_construct to avoid triggering __init__ side effects
445
+ case = Case.model_construct(**case_data)
446
+ # Manually add to registry since we bypassed __init__
447
+ Cases().add_case(case)
448
+ loaded_cases += 1
449
+ # log.debug(
450
+ # f"[Storage] Loaded running case: {case.id} (status: {case.status}, job: {case.job_id})"
451
+ # )
452
+ except Exception as e:
453
+ log.error(
454
+ f"[Storage] §SSL03 Failed to load case {case_data.get('id', 'unknown')}: {e}"
455
+ )
456
+
457
+ log.info(
458
+ f"[Storage] Entity re-loading complete: {loaded_jobs} running jobs, {loaded_cases} running cases"
459
+ )
460
+
461
+
462
+ storage_manager = StorageManager()
@@ -0,0 +1,81 @@
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 enum import Enum
9
+ from typing import Any, ClassVar, Dict
10
+
11
+ from pydantic import BaseModel
12
+
13
+ from supervaizer.__version__ import VERSION
14
+
15
+ # TODO: Uuse OpenTelemetry / OpenInference standard - Consider connecting to Arize Phoenix observability backend for storage and visualization.
16
+
17
+
18
+ class TelemetryType(str, Enum):
19
+ LOGS = "logs"
20
+ METRICS = "metrics"
21
+ EVENTS = "events"
22
+ TRACES = "traces"
23
+ EXCEPTIONS = "exceptions"
24
+ DIAGNOSTICS = "diagnostics"
25
+ CUSTOM = "custom"
26
+
27
+
28
+ class TelemetryCategory(str, Enum):
29
+ SYSTEM = "system"
30
+ APPLICATION = "application"
31
+ USER_INTERACTION = "user_interaction"
32
+ SECURITY = "security"
33
+ BUSINESS = "business"
34
+ ENVIRONMENT = "environment"
35
+ NETWORKING = "networking"
36
+ CUSTOM = "custom"
37
+
38
+
39
+ class TelemetrySeverity(str, Enum):
40
+ DEBUG = "debug"
41
+ INFO = "info"
42
+ WARNING = "warning"
43
+ ERROR = "error"
44
+ CRITICAL = "critical"
45
+
46
+
47
+ class TelemetryModel(BaseModel):
48
+ supervaizer_VERSION: ClassVar[str] = VERSION
49
+ agentId: str
50
+ type: TelemetryType
51
+ category: TelemetryCategory
52
+ severity: TelemetrySeverity
53
+ details: Dict[str, Any]
54
+
55
+
56
+ class Telemetry(TelemetryModel):
57
+ """Base class for all telemetry data in the Supervaize Control system.
58
+
59
+ Telemetry represents monitoring and observability data sent from agents to the control system.
60
+ This includes logs, metrics, events, traces, exceptions, diagnostics and custom telemetry.
61
+
62
+ Inherits from TelemetryModel which defines the core telemetry attributes:
63
+ - agentId: The ID of the agent sending the telemetry
64
+ - type: The TelemetryType enum indicating the telemetry category (logs, metrics, etc)
65
+ - category: The TelemetryCategory enum for the functional area (system, application, etc)
66
+ - severity: The TelemetrySeverity enum indicating importance (debug, info, warning, etc)
67
+ - details: A dictionary containing telemetry-specific details
68
+ """
69
+
70
+ def __init__(self, **kwargs: Any) -> None:
71
+ super().__init__(**kwargs)
72
+
73
+ @property
74
+ def payload(self) -> Dict[str, Any]:
75
+ return {
76
+ "agentId": self.agentId,
77
+ "eventType": self.type.value,
78
+ "severity": self.severity.value,
79
+ "eventCategory": self.category.value,
80
+ "details": self.details,
81
+ }
@@ -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