supervisely 6.73.410__py3-none-any.whl → 6.73.470__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.

Potentially problematic release.


This version of supervisely might be problematic. Click here for more details.

Files changed (190) hide show
  1. supervisely/__init__.py +136 -1
  2. supervisely/_utils.py +81 -0
  3. supervisely/annotation/json_geometries_map.py +2 -0
  4. supervisely/annotation/label.py +80 -3
  5. supervisely/api/annotation_api.py +9 -9
  6. supervisely/api/api.py +67 -43
  7. supervisely/api/app_api.py +72 -5
  8. supervisely/api/dataset_api.py +108 -33
  9. supervisely/api/entity_annotation/figure_api.py +113 -49
  10. supervisely/api/image_api.py +82 -0
  11. supervisely/api/module_api.py +10 -0
  12. supervisely/api/nn/deploy_api.py +15 -9
  13. supervisely/api/nn/ecosystem_models_api.py +201 -0
  14. supervisely/api/nn/neural_network_api.py +12 -3
  15. supervisely/api/pointcloud/pointcloud_api.py +38 -0
  16. supervisely/api/pointcloud/pointcloud_episode_annotation_api.py +3 -0
  17. supervisely/api/project_api.py +213 -6
  18. supervisely/api/task_api.py +11 -1
  19. supervisely/api/video/video_annotation_api.py +4 -2
  20. supervisely/api/video/video_api.py +79 -1
  21. supervisely/api/video/video_figure_api.py +24 -11
  22. supervisely/api/volume/volume_api.py +38 -0
  23. supervisely/app/__init__.py +1 -1
  24. supervisely/app/content.py +14 -6
  25. supervisely/app/fastapi/__init__.py +1 -0
  26. supervisely/app/fastapi/custom_static_files.py +1 -1
  27. supervisely/app/fastapi/multi_user.py +88 -0
  28. supervisely/app/fastapi/subapp.py +175 -42
  29. supervisely/app/fastapi/templating.py +1 -1
  30. supervisely/app/fastapi/websocket.py +77 -9
  31. supervisely/app/singleton.py +21 -0
  32. supervisely/app/v1/app_service.py +18 -2
  33. supervisely/app/v1/constants.py +7 -1
  34. supervisely/app/widgets/__init__.py +11 -1
  35. supervisely/app/widgets/agent_selector/template.html +1 -0
  36. supervisely/app/widgets/card/card.py +20 -0
  37. supervisely/app/widgets/dataset_thumbnail/dataset_thumbnail.py +11 -2
  38. supervisely/app/widgets/dataset_thumbnail/template.html +3 -1
  39. supervisely/app/widgets/deploy_model/deploy_model.py +750 -0
  40. supervisely/app/widgets/dialog/dialog.py +12 -0
  41. supervisely/app/widgets/dialog/template.html +2 -1
  42. supervisely/app/widgets/dropdown_checkbox_selector/__init__.py +0 -0
  43. supervisely/app/widgets/dropdown_checkbox_selector/dropdown_checkbox_selector.py +87 -0
  44. supervisely/app/widgets/dropdown_checkbox_selector/template.html +12 -0
  45. supervisely/app/widgets/ecosystem_model_selector/__init__.py +0 -0
  46. supervisely/app/widgets/ecosystem_model_selector/ecosystem_model_selector.py +195 -0
  47. supervisely/app/widgets/experiment_selector/experiment_selector.py +454 -263
  48. supervisely/app/widgets/fast_table/fast_table.py +713 -126
  49. supervisely/app/widgets/fast_table/script.js +492 -95
  50. supervisely/app/widgets/fast_table/style.css +54 -0
  51. supervisely/app/widgets/fast_table/template.html +45 -5
  52. supervisely/app/widgets/heatmap/__init__.py +0 -0
  53. supervisely/app/widgets/heatmap/heatmap.py +523 -0
  54. supervisely/app/widgets/heatmap/script.js +378 -0
  55. supervisely/app/widgets/heatmap/style.css +227 -0
  56. supervisely/app/widgets/heatmap/template.html +21 -0
  57. supervisely/app/widgets/input_tag/input_tag.py +102 -15
  58. supervisely/app/widgets/input_tag_list/__init__.py +0 -0
  59. supervisely/app/widgets/input_tag_list/input_tag_list.py +274 -0
  60. supervisely/app/widgets/input_tag_list/template.html +70 -0
  61. supervisely/app/widgets/radio_table/radio_table.py +10 -2
  62. supervisely/app/widgets/radio_tabs/radio_tabs.py +18 -2
  63. supervisely/app/widgets/radio_tabs/template.html +1 -0
  64. supervisely/app/widgets/select/select.py +6 -4
  65. supervisely/app/widgets/select_dataset/select_dataset.py +6 -0
  66. supervisely/app/widgets/select_dataset_tree/select_dataset_tree.py +83 -7
  67. supervisely/app/widgets/table/table.py +68 -13
  68. supervisely/app/widgets/tabs/tabs.py +22 -6
  69. supervisely/app/widgets/tabs/template.html +5 -1
  70. supervisely/app/widgets/transfer/style.css +3 -0
  71. supervisely/app/widgets/transfer/template.html +3 -1
  72. supervisely/app/widgets/transfer/transfer.py +48 -45
  73. supervisely/app/widgets/tree_select/tree_select.py +2 -0
  74. supervisely/convert/image/csv/csv_converter.py +24 -15
  75. supervisely/convert/pointcloud/nuscenes_conv/nuscenes_converter.py +43 -41
  76. supervisely/convert/pointcloud_episodes/nuscenes_conv/nuscenes_converter.py +75 -51
  77. supervisely/convert/pointcloud_episodes/nuscenes_conv/nuscenes_helper.py +137 -124
  78. supervisely/convert/video/video_converter.py +2 -2
  79. supervisely/geometry/polyline_3d.py +110 -0
  80. supervisely/io/env.py +161 -1
  81. supervisely/nn/artifacts/__init__.py +1 -1
  82. supervisely/nn/artifacts/artifacts.py +10 -2
  83. supervisely/nn/artifacts/detectron2.py +1 -0
  84. supervisely/nn/artifacts/hrda.py +1 -0
  85. supervisely/nn/artifacts/mmclassification.py +20 -0
  86. supervisely/nn/artifacts/mmdetection.py +5 -3
  87. supervisely/nn/artifacts/mmsegmentation.py +1 -0
  88. supervisely/nn/artifacts/ritm.py +1 -0
  89. supervisely/nn/artifacts/rtdetr.py +1 -0
  90. supervisely/nn/artifacts/unet.py +1 -0
  91. supervisely/nn/artifacts/utils.py +3 -0
  92. supervisely/nn/artifacts/yolov5.py +2 -0
  93. supervisely/nn/artifacts/yolov8.py +1 -0
  94. supervisely/nn/benchmark/semantic_segmentation/metric_provider.py +18 -18
  95. supervisely/nn/experiments.py +9 -0
  96. supervisely/nn/inference/cache.py +37 -17
  97. supervisely/nn/inference/gui/serving_gui_template.py +39 -13
  98. supervisely/nn/inference/inference.py +953 -211
  99. supervisely/nn/inference/inference_request.py +15 -8
  100. supervisely/nn/inference/instance_segmentation/instance_segmentation.py +1 -0
  101. supervisely/nn/inference/object_detection/object_detection.py +1 -0
  102. supervisely/nn/inference/predict_app/__init__.py +0 -0
  103. supervisely/nn/inference/predict_app/gui/__init__.py +0 -0
  104. supervisely/nn/inference/predict_app/gui/classes_selector.py +160 -0
  105. supervisely/nn/inference/predict_app/gui/gui.py +915 -0
  106. supervisely/nn/inference/predict_app/gui/input_selector.py +344 -0
  107. supervisely/nn/inference/predict_app/gui/model_selector.py +77 -0
  108. supervisely/nn/inference/predict_app/gui/output_selector.py +179 -0
  109. supervisely/nn/inference/predict_app/gui/preview.py +93 -0
  110. supervisely/nn/inference/predict_app/gui/settings_selector.py +881 -0
  111. supervisely/nn/inference/predict_app/gui/tags_selector.py +110 -0
  112. supervisely/nn/inference/predict_app/gui/utils.py +399 -0
  113. supervisely/nn/inference/predict_app/predict_app.py +176 -0
  114. supervisely/nn/inference/session.py +47 -39
  115. supervisely/nn/inference/tracking/bbox_tracking.py +5 -1
  116. supervisely/nn/inference/tracking/point_tracking.py +5 -1
  117. supervisely/nn/inference/tracking/tracker_interface.py +4 -0
  118. supervisely/nn/inference/uploader.py +9 -5
  119. supervisely/nn/model/model_api.py +44 -22
  120. supervisely/nn/model/prediction.py +15 -1
  121. supervisely/nn/model/prediction_session.py +70 -14
  122. supervisely/nn/prediction_dto.py +7 -0
  123. supervisely/nn/tracker/__init__.py +6 -8
  124. supervisely/nn/tracker/base_tracker.py +54 -0
  125. supervisely/nn/tracker/botsort/__init__.py +1 -0
  126. supervisely/nn/tracker/botsort/botsort_config.yaml +30 -0
  127. supervisely/nn/tracker/botsort/osnet_reid/__init__.py +0 -0
  128. supervisely/nn/tracker/botsort/osnet_reid/osnet.py +566 -0
  129. supervisely/nn/tracker/botsort/osnet_reid/osnet_reid_interface.py +88 -0
  130. supervisely/nn/tracker/botsort/tracker/__init__.py +0 -0
  131. supervisely/nn/tracker/{bot_sort → botsort/tracker}/basetrack.py +1 -2
  132. supervisely/nn/tracker/{utils → botsort/tracker}/gmc.py +51 -59
  133. supervisely/nn/tracker/{deep_sort/deep_sort → botsort/tracker}/kalman_filter.py +71 -33
  134. supervisely/nn/tracker/botsort/tracker/matching.py +202 -0
  135. supervisely/nn/tracker/{bot_sort/bot_sort.py → botsort/tracker/mc_bot_sort.py} +68 -81
  136. supervisely/nn/tracker/botsort_tracker.py +273 -0
  137. supervisely/nn/tracker/calculate_metrics.py +264 -0
  138. supervisely/nn/tracker/utils.py +273 -0
  139. supervisely/nn/tracker/visualize.py +520 -0
  140. supervisely/nn/training/gui/gui.py +152 -49
  141. supervisely/nn/training/gui/hyperparameters_selector.py +1 -1
  142. supervisely/nn/training/gui/model_selector.py +8 -6
  143. supervisely/nn/training/gui/train_val_splits_selector.py +144 -71
  144. supervisely/nn/training/gui/training_artifacts.py +3 -1
  145. supervisely/nn/training/train_app.py +225 -46
  146. supervisely/project/pointcloud_episode_project.py +12 -8
  147. supervisely/project/pointcloud_project.py +12 -8
  148. supervisely/project/project.py +221 -75
  149. supervisely/template/experiment/experiment.html.jinja +105 -55
  150. supervisely/template/experiment/experiment_generator.py +258 -112
  151. supervisely/template/experiment/header.html.jinja +31 -13
  152. supervisely/template/experiment/sly-style.css +7 -2
  153. supervisely/versions.json +3 -1
  154. supervisely/video/sampling.py +42 -20
  155. supervisely/video/video.py +41 -12
  156. supervisely/video_annotation/video_figure.py +38 -4
  157. supervisely/volume/stl_converter.py +2 -0
  158. supervisely/worker_api/agent_rpc.py +24 -1
  159. supervisely/worker_api/rpc_servicer.py +31 -7
  160. {supervisely-6.73.410.dist-info → supervisely-6.73.470.dist-info}/METADATA +22 -14
  161. {supervisely-6.73.410.dist-info → supervisely-6.73.470.dist-info}/RECORD +167 -148
  162. supervisely_lib/__init__.py +6 -1
  163. supervisely/app/widgets/experiment_selector/style.css +0 -27
  164. supervisely/app/widgets/experiment_selector/template.html +0 -61
  165. supervisely/nn/tracker/bot_sort/__init__.py +0 -21
  166. supervisely/nn/tracker/bot_sort/fast_reid_interface.py +0 -152
  167. supervisely/nn/tracker/bot_sort/matching.py +0 -127
  168. supervisely/nn/tracker/bot_sort/sly_tracker.py +0 -401
  169. supervisely/nn/tracker/deep_sort/__init__.py +0 -6
  170. supervisely/nn/tracker/deep_sort/deep_sort/__init__.py +0 -1
  171. supervisely/nn/tracker/deep_sort/deep_sort/detection.py +0 -49
  172. supervisely/nn/tracker/deep_sort/deep_sort/iou_matching.py +0 -81
  173. supervisely/nn/tracker/deep_sort/deep_sort/linear_assignment.py +0 -202
  174. supervisely/nn/tracker/deep_sort/deep_sort/nn_matching.py +0 -176
  175. supervisely/nn/tracker/deep_sort/deep_sort/track.py +0 -166
  176. supervisely/nn/tracker/deep_sort/deep_sort/tracker.py +0 -145
  177. supervisely/nn/tracker/deep_sort/deep_sort.py +0 -301
  178. supervisely/nn/tracker/deep_sort/generate_clip_detections.py +0 -90
  179. supervisely/nn/tracker/deep_sort/preprocessing.py +0 -70
  180. supervisely/nn/tracker/deep_sort/sly_tracker.py +0 -273
  181. supervisely/nn/tracker/tracker.py +0 -285
  182. supervisely/nn/tracker/utils/kalman_filter.py +0 -492
  183. supervisely/nn/tracking/__init__.py +0 -1
  184. supervisely/nn/tracking/boxmot.py +0 -114
  185. supervisely/nn/tracking/tracking.py +0 -24
  186. /supervisely/{nn/tracker/utils → app/widgets/deploy_model}/__init__.py +0 -0
  187. {supervisely-6.73.410.dist-info → supervisely-6.73.470.dist-info}/LICENSE +0 -0
  188. {supervisely-6.73.410.dist-info → supervisely-6.73.470.dist-info}/WHEEL +0 -0
  189. {supervisely-6.73.410.dist-info → supervisely-6.73.470.dist-info}/entry_points.txt +0 -0
  190. {supervisely-6.73.410.dist-info → supervisely-6.73.470.dist-info}/top_level.txt +0 -0
@@ -42,7 +42,7 @@ class CustomStaticFiles(StaticFiles):
42
42
  def _get_range_header(range_header: str, file_size: int) -> typing.Tuple[int, int]:
43
43
  def _invalid_range():
44
44
  return HTTPException(
45
- status.HTTP_416_REQUESTED_RANGE_NOT_SATISFIABLE,
45
+ status.HTTP_416_REQUESTED_RANGE_NOT_SATISFIABLE, #TODO: change to status.HTTP_416_RANGE_NOT_SATISFIABLE if update starlette to 0.48.0+
46
46
  detail=f"Invalid request range (Range:{range_header!r})",
47
47
  )
48
48
 
@@ -0,0 +1,88 @@
1
+ import hashlib
2
+ from contextlib import contextmanager
3
+ from typing import Optional, Union
4
+
5
+ from fastapi import Request
6
+
7
+ import supervisely.io.env as sly_env
8
+ from supervisely.api.module_api import ApiField
9
+ from supervisely.app.fastapi.websocket import WebsocketManager
10
+ from supervisely.sly_logger import logger
11
+
12
+
13
+ def _parse_int(value):
14
+ try:
15
+ return int(value)
16
+ except (TypeError, ValueError):
17
+ return None
18
+
19
+
20
+ def _user_identity_from_cookie(request: Request) -> Optional[str]:
21
+ cookie_header = request.headers.get("cookie")
22
+ if not cookie_header:
23
+ return None
24
+ return hashlib.sha256(cookie_header.encode("utf-8")).hexdigest()
25
+
26
+
27
+ async def extract_user_id_from_request(request: Request) -> Optional[Union[int, str]]:
28
+ """Extract user ID from various parts of the request."""
29
+ if not sly_env.is_multiuser_mode_enabled():
30
+ return None
31
+ user_id = _parse_int(request.query_params.get("userId"))
32
+ if user_id is None:
33
+ header_user = _parse_int(request.headers.get("x-user-id"))
34
+ if header_user is not None:
35
+ user_id = header_user
36
+ if user_id is None:
37
+ referer = request.headers.get("referer", "")
38
+ if referer:
39
+ from urllib.parse import parse_qs, urlparse
40
+
41
+ try:
42
+ parsed_url = urlparse(referer)
43
+ query_params = parse_qs(parsed_url.query)
44
+ referer_user = query_params.get("userId", [None])[0]
45
+ user_id = _parse_int(referer_user)
46
+ except Exception as e:
47
+ logger.error(f"Error parsing userId from referer: {e}")
48
+ if user_id is None and "application/json" in request.headers.get("Content-Type", ""):
49
+ try:
50
+ payload = await request.json()
51
+ except Exception:
52
+ payload = {}
53
+ context = payload.get("context") or {}
54
+ user_id = _parse_int(context.get("userId") or context.get(ApiField.USER_ID))
55
+ if user_id is None:
56
+ state_payload = payload.get("state") or {}
57
+ user_id = _parse_int(state_payload.get("userId") or state_payload.get(ApiField.USER_ID))
58
+ if user_id is None:
59
+ user_id = _user_identity_from_cookie(request)
60
+ return user_id
61
+
62
+
63
+ @contextmanager
64
+ def session_context(user_id: Optional[Union[int, str]]):
65
+ """
66
+ Context manager to set and reset user context for multiuser applications.
67
+ Call this at the beginning of a request handling to ensure the correct user context is set in environment variables (`supervisely_multiuser_app_user_id` ContextVar).
68
+ """
69
+ if not sly_env.is_multiuser_mode_enabled() or user_id is None:
70
+ yield
71
+ return
72
+ token = sly_env.set_user_for_multiuser_app(user_id)
73
+ try:
74
+ yield
75
+ finally:
76
+ sly_env.reset_user_for_multiuser_app(token)
77
+
78
+
79
+ def remember_cookie(request: Request, user_id: Optional[Union[int, str]]):
80
+ """
81
+ Remember user cookie for the given user ID. This is used to associate WebSocket connections with users in multiuser applications based on cookies.
82
+ Allows WebSocket connections to be correctly routed to the appropriate user.
83
+ """
84
+ if not sly_env.is_multiuser_mode_enabled() or user_id is None:
85
+ return
86
+ cookie_header = request.headers.get("cookie")
87
+ if cookie_header:
88
+ WebsocketManager().remember_user_cookie(cookie_header, user_id)
@@ -1,21 +1,25 @@
1
+ import hashlib
1
2
  import inspect
2
3
  import json
3
4
  import os
4
5
  import signal
5
6
  import sys
6
- from contextlib import suppress
7
+ import time
8
+ from contextlib import contextmanager, suppress
9
+ from contextvars import ContextVar
7
10
  from functools import wraps
8
11
  from pathlib import Path
9
12
  from threading import Event as ThreadingEvent
10
13
  from threading import Thread
11
14
  from time import sleep
12
- from typing import TYPE_CHECKING, Any, Callable, Dict, List, Optional
15
+ from typing import TYPE_CHECKING, Any, Callable, Dict, List, Optional, Union
13
16
 
14
17
  import arel
15
18
  import jinja2
16
19
  import numpy as np
17
20
  import psutil
18
21
  from async_asgi_testclient import TestClient
22
+ from cachetools import TTLCache
19
23
  from fastapi import (
20
24
  Depends,
21
25
  FastAPI,
@@ -30,6 +34,7 @@ from fastapi.responses import JSONResponse
30
34
  from fastapi.routing import APIRouter
31
35
  from fastapi.staticfiles import StaticFiles
32
36
 
37
+ import supervisely.app.fastapi.multi_user as multi_user
33
38
  import supervisely.io.env as sly_env
34
39
  from supervisely._utils import (
35
40
  is_debug_with_sly_net,
@@ -61,6 +66,14 @@ SUPERVISELY_SERVER_PATH_PREFIX = sly_env.supervisely_server_path_prefix()
61
66
  if SUPERVISELY_SERVER_PATH_PREFIX and not SUPERVISELY_SERVER_PATH_PREFIX.startswith("/"):
62
67
  SUPERVISELY_SERVER_PATH_PREFIX = f"/{SUPERVISELY_SERVER_PATH_PREFIX}"
63
68
 
69
+ HEALTH_ENDPOINTS = ["/health", "/is_ready"]
70
+
71
+ # Context variable for response time
72
+ response_time_ctx: ContextVar[float] = ContextVar("response_time", default=None)
73
+
74
+ # Mapping from user_id to Api instance
75
+ _USER_API_CACHE = TTLCache(maxsize=500, ttl=60 * 15) # Cache up to 15 minutes
76
+
64
77
 
65
78
  class ReadyzFilter(logging.Filter):
66
79
  def filter(self, record):
@@ -70,11 +83,22 @@ class ReadyzFilter(logging.Filter):
70
83
  return True
71
84
 
72
85
 
86
+ class ResponseTimeFilter(logging.Filter):
87
+ def filter(self, record):
88
+ # Check if this is an HTTP access log line by logger name
89
+ if getattr(record, "name", "") == "uvicorn.access":
90
+ response_time = response_time_ctx.get(None)
91
+ if response_time is not None:
92
+ record.responseTime = int(response_time)
93
+ return True
94
+
95
+
73
96
  def _init_uvicorn_logger():
74
97
  uvicorn_logger = logging.getLogger("uvicorn.access")
75
98
  for handler in uvicorn_logger.handlers:
76
99
  handler.setFormatter(create_formatter())
77
100
  uvicorn_logger.addFilter(ReadyzFilter())
101
+ uvicorn_logger.addFilter(ResponseTimeFilter())
78
102
 
79
103
 
80
104
  _init_uvicorn_logger()
@@ -606,18 +630,30 @@ def create(
606
630
  shutdown(process_id, before_shutdown_callbacks)
607
631
 
608
632
  if headless is False:
609
-
610
633
  @app.post("/data")
611
634
  async def send_data(request: Request):
612
- data = DataJson()
613
- response = JSONResponse(content=dict(data))
635
+ if not sly_env.is_multiuser_mode_enabled():
636
+ data = DataJson()
637
+ response = JSONResponse(content=dict(data))
638
+ return response
639
+ user_id = await multi_user.extract_user_id_from_request(request)
640
+ multi_user.remember_cookie(request, user_id)
641
+ with multi_user.session_context(user_id):
642
+ data = DataJson()
643
+ response = JSONResponse(content=dict(data))
614
644
  return response
615
645
 
616
646
  @app.post("/state")
617
647
  async def send_state(request: Request):
618
- state = StateJson()
619
-
620
- response = JSONResponse(content=dict(state))
648
+ if not sly_env.is_multiuser_mode_enabled():
649
+ state = StateJson()
650
+ response = JSONResponse(content=dict(state))
651
+ else:
652
+ user_id = await multi_user.extract_user_id_from_request(request)
653
+ multi_user.remember_cookie(request, user_id)
654
+ with multi_user.session_context(user_id):
655
+ state = StateJson()
656
+ response = JSONResponse(content=dict(state))
621
657
  gettrace = getattr(sys, "gettrace", None)
622
658
  if (gettrace is not None and gettrace()) or is_development():
623
659
  response.headers["x-debug-mode"] = "1"
@@ -794,41 +830,64 @@ def _init(
794
830
 
795
831
  @app.middleware("http")
796
832
  async def get_state_from_request(request: Request, call_next):
797
- if headless is False:
798
- await StateJson.from_request(request)
799
-
800
- if not ("application/json" not in request.headers.get("Content-Type", "")):
801
- # {'command': 'inference_batch_ids', 'context': {}, 'state': {'dataset_id': 49711, 'batch_ids': [3120204], 'settings': None}, 'user_api_key': 'XXX', 'api_token': 'XXX', 'instance_type': None, 'server_address': 'https://app.supervisely.com'}
802
- content = await request.json()
803
-
804
- request.state.context = content.get("context")
805
- request.state.state = content.get("state")
806
- request.state.api_token = content.get(
807
- "api_token",
808
- (
809
- request.state.context.get("apiToken")
810
- if request.state.context is not None
811
- else None
812
- ),
813
- )
814
- # logger.debug(f"middleware request api_token {request.state.api_token}")
815
- request.state.server_address = content.get(
816
- "server_address", sly_env.server_address(raise_not_found=False)
817
- )
818
- # request.state.server_address = sly_env.server_address(raise_not_found=False)
819
- # logger.debug(f"middleware request server_address {request.state.server_address}")
820
- # logger.debug(f"middleware request context {request.state.context}")
821
- # logger.debug(f"middleware request state {request.state.state}")
822
- if request.state.server_address is not None and request.state.api_token is not None:
823
- request.state.api = Api(request.state.server_address, request.state.api_token)
824
- else:
825
- request.state.api = None
833
+ # Start timer for response time measurement
834
+ start_time = time.perf_counter()
835
+
836
+ async def _process_request(request: Request, call_next):
837
+ if "application/json" in request.headers.get("Content-Type", ""):
838
+ content = await request.json()
839
+ request.state.context = content.get("context")
840
+ request.state.state = content.get("state")
841
+ request.state.api_token = content.get(
842
+ "api_token",
843
+ (
844
+ request.state.context.get("apiToken")
845
+ if request.state.context is not None
846
+ else None
847
+ ),
848
+ )
849
+ request.state.server_address = content.get(
850
+ "server_address", sly_env.server_address(raise_not_found=False)
851
+ )
852
+ if (
853
+ request.state.server_address is not None
854
+ and request.state.api_token is not None
855
+ ):
856
+ request.state.api = Api(
857
+ request.state.server_address, request.state.api_token
858
+ )
859
+ if sly_env.is_multiuser_mode_enabled():
860
+ user_id = sly_env.user_from_multiuser_app()
861
+ if user_id is not None:
862
+ _USER_API_CACHE[user_id] = request.state.api
863
+ else:
864
+ request.state.api = None
826
865
 
827
- try:
828
- response = await call_next(request)
829
- except Exception as exc:
830
- need_to_handle_error = is_production()
831
- response = await process_server_error(request, exc, need_to_handle_error)
866
+ try:
867
+ response = await call_next(request)
868
+ except Exception as exc:
869
+ need_to_handle_error = is_production()
870
+ response = await process_server_error(
871
+ request, exc, need_to_handle_error
872
+ )
873
+
874
+ return response
875
+
876
+ if not sly_env.is_multiuser_mode_enabled():
877
+ if headless is False:
878
+ await StateJson.from_request(request)
879
+ response = await _process_request(request, call_next)
880
+ else:
881
+ user_id = await multi_user.extract_user_id_from_request(request)
882
+ multi_user.remember_cookie(request, user_id)
883
+
884
+ with multi_user.session_context(user_id):
885
+ if headless is False:
886
+ await StateJson.from_request(request, local=False)
887
+ response = await _process_request(request, call_next)
888
+ # Calculate response time and set it for uvicorn logger in ms
889
+ elapsed_ms = round((time.perf_counter() - start_time) * 1000)
890
+ response_time_ctx.set(elapsed_ms)
832
891
  return response
833
892
 
834
893
  def verify_localhost(request: Request):
@@ -905,7 +964,33 @@ class Application(metaclass=Singleton):
905
964
  Callable
906
965
  ] = None, # function to check if the app is ready for requests (e.g serving app: model is served and ready)
907
966
  show_header: bool = True,
967
+ hide_health_check_logs: bool = True, # whether to hide health check logs in info level
968
+ health_check_endpoints: Optional[List[str]] = None, # endpoints to check health of the app
908
969
  ):
970
+ """Initialize the Supervisely Application.
971
+
972
+ :param layout: Main layout of the application.
973
+ :type layout: Widget
974
+ :param templates_dir: Directory with Jinja2 templates. It is preferred to use `layout` instead of `templates_dir`.
975
+ :type templates_dir: str, optional
976
+ :param static_dir: Directory with static files (e.g. CSS, JS), used for serving static content.
977
+ :type static_dir: str, optional
978
+ :param hot_reload: Whether to enable hot reload during development (default is False).
979
+ :type hot_reload: bool, optional
980
+ :param session_info_extra_content: Additional content to be displayed in the session info area.
981
+ :type session_info_extra_content: Widget, optional
982
+ :param session_info_solid: Whether to use solid background for the session info area.
983
+ :type session_info_solid: bool, optional
984
+ :param ready_check_function: Function to check if the app is ready for requests.
985
+ :type ready_check_function: Callable, optional
986
+ :param show_header: Whether to show the header in the application.
987
+ :type show_header: bool, optional
988
+ :param hide_health_check_logs: Whether to hide health check logs in info level.
989
+ :type hide_health_check_logs: bool, optional
990
+ :param health_check_endpoints: List of additional endpoints to check health of the app.
991
+ Add your custom endpoints here to be able to manage logging of health check requests on info level with `hide_health_check_logs`.
992
+ :type health_check_endpoints: List[str], optional
993
+ """
909
994
  self._favicon = os.environ.get("icon", "https://cdn.supervisely.com/favicon.ico")
910
995
  JinjaWidgets().context["__favicon__"] = self._favicon
911
996
  JinjaWidgets().context["__no_html_mode__"] = True
@@ -980,6 +1065,17 @@ class Application(metaclass=Singleton):
980
1065
  hot_reload=hot_reload,
981
1066
  before_shutdown_callbacks=self._before_shutdown_callbacks,
982
1067
  )
1068
+
1069
+ # add filter to hide health check logs for info level
1070
+ if health_check_endpoints is None or len(health_check_endpoints) == 0:
1071
+ self._health_check_endpoints = HEALTH_ENDPOINTS
1072
+ else:
1073
+ health_check_endpoints = [endpoint.strip() for endpoint in health_check_endpoints]
1074
+ self._health_check_endpoints = HEALTH_ENDPOINTS + health_check_endpoints
1075
+
1076
+ if hide_health_check_logs:
1077
+ self._setup_health_check_filter()
1078
+
983
1079
  self.test_client = TestClient(self._fastapi)
984
1080
 
985
1081
  if not headless:
@@ -1126,6 +1222,34 @@ class Application(metaclass=Singleton):
1126
1222
  def set_ready_check_function(self, func: Callable):
1127
1223
  self._ready_check_function = func
1128
1224
 
1225
+ def _setup_health_check_filter(self):
1226
+ """Setup filter to hide health check logs for info level."""
1227
+
1228
+ class HealthCheckFilter(logging.Filter):
1229
+ def __init__(self, app_instance):
1230
+ super().__init__()
1231
+ self.app: Application = app_instance
1232
+
1233
+ def filter(self, record):
1234
+ # Hide health check requests if NOT in debug mode
1235
+ if not self.app._fastapi.debug and hasattr(record, "getMessage"):
1236
+ message = record.getMessage()
1237
+ # Check if the message contains health check paths
1238
+ if any(path in message for path in self.app._health_check_endpoints):
1239
+ return False
1240
+ return True
1241
+
1242
+ # Apply filter to uvicorn access logger
1243
+ health_filter = HealthCheckFilter(self)
1244
+ uvicorn_logger = logging.getLogger("uvicorn.access")
1245
+
1246
+ # Remove old filters of this type, if any (for safety)
1247
+ uvicorn_logger.filters = [
1248
+ f for f in uvicorn_logger.filters if not isinstance(f, HealthCheckFilter)
1249
+ ]
1250
+
1251
+ uvicorn_logger.addFilter(health_filter)
1252
+
1129
1253
 
1130
1254
  def set_autostart_flag_from_state(default: Optional[str] = None):
1131
1255
  """Set `autostart` flag recieved from task state. Env name: `modal.state.autostart`.
@@ -1190,3 +1314,12 @@ def call_on_autostart(
1190
1314
 
1191
1315
  def get_name_from_env(default="Supervisely App"):
1192
1316
  return os.environ.get("APP_NAME", default)
1317
+
1318
+ def session_user_api() -> Optional[Api]:
1319
+ """Returns the API instance for the current session user."""
1320
+ if not sly_env.is_multiuser_mode_enabled():
1321
+ return Api.from_env()
1322
+ user_id = sly_env.user_from_multiuser_app()
1323
+ if user_id is None:
1324
+ return None
1325
+ return _USER_API_CACHE.get(user_id, None)
@@ -11,7 +11,7 @@ from supervisely.app.singleton import Singleton
11
11
  from supervisely.app.widgets_context import JinjaWidgets
12
12
 
13
13
  # https://github.com/supervisely/js-bundle
14
- js_bundle_version = "2.1.99"
14
+ js_bundle_version = "2.2.2"
15
15
 
16
16
  # https://github.com/supervisely-ecosystem/supervisely-app-frontend-js
17
17
  js_frontend_version = "v0.0.56"
@@ -1,5 +1,10 @@
1
- from typing import List
1
+ import hashlib
2
+ import time
3
+ from typing import Dict, List, Optional, Tuple, Union
4
+
2
5
  from fastapi import FastAPI, WebSocket, WebSocketDisconnect
6
+
7
+ import supervisely.io.env as sly_env
3
8
  from supervisely.app.singleton import Singleton
4
9
 
5
10
 
@@ -8,6 +13,9 @@ class WebsocketManager(metaclass=Singleton):
8
13
  self.app = None
9
14
  self.path = path
10
15
  self.active_connections: List[WebSocket] = []
16
+ self._connection_users: Dict[WebSocket, Optional[Union[int, str]]] = {}
17
+ self._cookie_user_map: Dict[str, Tuple[Union[int, str], float]] = {}
18
+ self._cookie_ttl_seconds = 60 * 60
11
19
 
12
20
  def set_app(self, app: FastAPI):
13
21
  if self.app is not None:
@@ -17,17 +25,42 @@ class WebsocketManager(metaclass=Singleton):
17
25
 
18
26
  async def connect(self, websocket: WebSocket):
19
27
  await websocket.accept()
28
+ user_id = self._resolve_user_id(websocket)
20
29
  self.active_connections.append(websocket)
30
+ self._connection_users[websocket] = user_id
21
31
 
22
32
  def disconnect(self, websocket: WebSocket):
23
- self.active_connections.remove(websocket)
24
-
25
- async def broadcast(self, d: dict):
26
- # if self.app is None:
27
- # raise ValueError(
28
- # "WebSocket is not initialized, use Websocket middleware for that"
29
- # )
30
- for connection in self.active_connections:
33
+ if websocket in self.active_connections:
34
+ self.active_connections.remove(websocket)
35
+ self._connection_users.pop(websocket, None)
36
+
37
+ def remember_user_cookie(
38
+ self, cookie_header: Optional[str], user_id: Optional[Union[int, str]]
39
+ ):
40
+ if cookie_header is None or user_id is None:
41
+ return
42
+ fingerprint = self._cookie_fingerprint(cookie_header)
43
+ if fingerprint is None:
44
+ return
45
+ self._purge_cookie_cache()
46
+ self._cookie_user_map[fingerprint] = (user_id, time.monotonic())
47
+
48
+ async def broadcast(self, d: dict, user_id: Optional[Union[int, str]] = None):
49
+ if sly_env.is_multiuser_mode_enabled():
50
+ if user_id is None:
51
+ user_id = sly_env.user_from_multiuser_app()
52
+ if user_id is None:
53
+ targets = list(self.active_connections)
54
+ else:
55
+ targets = [
56
+ connection
57
+ for connection in self.active_connections
58
+ if self._connection_users.get(connection) == user_id
59
+ ]
60
+ else:
61
+ targets = list(self.active_connections)
62
+
63
+ for connection in list(targets):
31
64
  await connection.send_json(d)
32
65
 
33
66
  async def endpoint(self, websocket: WebSocket):
@@ -37,3 +70,38 @@ class WebsocketManager(metaclass=Singleton):
37
70
  data = await websocket.receive_text()
38
71
  except WebSocketDisconnect:
39
72
  self.disconnect(websocket)
73
+
74
+ def _resolve_user_id(self, websocket: WebSocket) -> Optional[int]:
75
+ if not sly_env.is_multiuser_mode_enabled():
76
+ return None
77
+ query_user = websocket.query_params.get("userId")
78
+ if query_user is not None:
79
+ try:
80
+ return int(query_user)
81
+ except ValueError:
82
+ pass
83
+ fingerprint = self._cookie_fingerprint(websocket.headers.get("cookie"))
84
+ if fingerprint is None:
85
+ return None
86
+ cached = self._cookie_user_map.get(fingerprint)
87
+ if cached is None:
88
+ return None
89
+ user_id, ts = cached
90
+ if time.monotonic() - ts > self._cookie_ttl_seconds:
91
+ self._cookie_user_map.pop(fingerprint, None)
92
+ return None
93
+ return user_id
94
+
95
+ @staticmethod
96
+ def _cookie_fingerprint(cookie_header: Optional[str]) -> Optional[str]:
97
+ if not cookie_header:
98
+ return None
99
+ return hashlib.sha256(cookie_header.encode("utf-8")).hexdigest()
100
+
101
+ def _purge_cookie_cache(self) -> None:
102
+ if not self._cookie_user_map:
103
+ return
104
+ cutoff = time.monotonic() - self._cookie_ttl_seconds
105
+ expired = [key for key, (_, ts) in self._cookie_user_map.items() if ts < cutoff]
106
+ for key in expired:
107
+ self._cookie_user_map.pop(key, None)
@@ -1,11 +1,32 @@
1
+ import supervisely.io.env as sly_env
2
+
3
+
1
4
  class Singleton(type):
2
5
  _instances = {}
6
+ _nested_instances = {}
3
7
 
4
8
  def __call__(cls, *args, **kwargs):
5
9
  local = kwargs.pop("__local__", False)
6
10
  if local is False:
7
11
  if cls not in cls._instances:
8
12
  cls._instances[cls] = super(Singleton, cls).__call__(*args, **kwargs)
13
+
14
+ if sly_env.is_multiuser_mode_enabled():
15
+ from supervisely.app.content import DataJson, StateJson
16
+ from copy import deepcopy
17
+
18
+ # Initialize nested instances dict once
19
+ nested_instances = cls._nested_instances.setdefault(cls, {})
20
+
21
+ user_id = sly_env.user_from_multiuser_app()
22
+ if user_id is not None and cls in (StateJson, DataJson):
23
+ if user_id not in nested_instances:
24
+ # Create new instance and copy data
25
+ instance = super(Singleton, cls).__call__(*args, **kwargs)
26
+ instance.update(deepcopy(dict(cls._instances[cls])))
27
+ nested_instances[user_id] = instance
28
+
29
+ return nested_instances[user_id]
9
30
  return cls._instances[cls]
10
31
  else:
11
32
  return super(Singleton, cls).__call__(*args, **kwargs)
@@ -1,3 +1,5 @@
1
+ # isort: skip_file
2
+
1
3
  import json
2
4
  import os
3
5
  import time
@@ -12,7 +14,8 @@ import queue
12
14
  import re
13
15
 
14
16
  from supervisely.worker_api.agent_api import AgentAPI
15
- from supervisely.worker_proto import worker_api_pb2 as api_proto
17
+
18
+ # from supervisely.worker_proto import worker_api_pb2 as api_proto # Import moved to methods where needed
16
19
  from supervisely.function_wrapper import function_wrapper
17
20
  from supervisely._utils import take_with_default
18
21
  from supervisely.sly_logger import logger as default_logger
@@ -30,7 +33,6 @@ from supervisely._utils import _remove_sensitive_information
30
33
  from supervisely.worker_api.agent_rpc import send_from_memory_generator
31
34
  from supervisely.io.fs_cache import FileCache
32
35
 
33
-
34
36
  # https://www.roguelynn.com/words/asyncio-we-did-it-wrong/
35
37
 
36
38
 
@@ -390,6 +392,13 @@ class AppService:
390
392
  )
391
393
 
392
394
  def publish_sync(self, initial_events=None):
395
+ try:
396
+ from supervisely.worker_proto import worker_api_pb2 as api_proto
397
+ except Exception as e:
398
+ from supervisely.app.v1.constants import PROTOBUF_REQUIRED_ERROR
399
+
400
+ raise ImportError(PROTOBUF_REQUIRED_ERROR) from e
401
+
393
402
  if initial_events is not None:
394
403
  for event_obj in initial_events:
395
404
  event_obj["api_token"] = os.environ[API_TOKEN]
@@ -507,6 +516,13 @@ class AppService:
507
516
  self._error = error
508
517
 
509
518
  def send_response(self, request_id, data):
519
+ try:
520
+ from supervisely.worker_proto import worker_api_pb2 as api_proto
521
+ except Exception as e:
522
+ from supervisely.app.v1.constants import PROTOBUF_REQUIRED_ERROR
523
+
524
+ raise ImportError(PROTOBUF_REQUIRED_ERROR) from e
525
+
510
526
  out_bytes = json.dumps(data).encode("utf-8")
511
527
  self.api.put_stream_with_data(
512
528
  "SendGeneralEventData",
@@ -7,4 +7,10 @@ SHARED_DATA = '/sessions'
7
7
 
8
8
  STOP_COMMAND = "stop"
9
9
 
10
- IMAGE_ANNOTATION_EVENTS = ["manual_selected_figure_changed"]
10
+ IMAGE_ANNOTATION_EVENTS = ["manual_selected_figure_changed"]
11
+
12
+ # Error message for missing or incompatible protobuf dependencies
13
+ PROTOBUF_REQUIRED_ERROR = (
14
+ "protobuf is required for agent/worker/app_v1 functionality. "
15
+ "Please install supervisely with agent extras: pip install 'supervisely[agent]'"
16
+ )
@@ -62,6 +62,7 @@ from supervisely.app.widgets.video_player.video_player import VideoPlayer
62
62
  from supervisely.app.widgets.radio_group.radio_group import RadioGroup
63
63
  from supervisely.app.widgets.switch.switch import Switch
64
64
  from supervisely.app.widgets.input_tag.input_tag import InputTag
65
+
65
66
  from supervisely.app.widgets.file_viewer.file_viewer import FileViewer
66
67
  from supervisely.app.widgets.switch.switch import Switch
67
68
  from supervisely.app.widgets.folder_thumbnail.folder_thumbnail import FolderThumbnail
@@ -151,4 +152,13 @@ from supervisely.app.widgets.experiment_selector.experiment_selector import Expe
151
152
  from supervisely.app.widgets.bokeh.bokeh import Bokeh
152
153
  from supervisely.app.widgets.run_app_button.run_app_button import RunAppButton
153
154
  from supervisely.app.widgets.select_collection.select_collection import SelectCollection
154
- from supervisely.app.widgets.sampling.sampling import Sampling
155
+ from supervisely.app.widgets.sampling.sampling import Sampling
156
+ from supervisely.app.widgets.input_tag_list.input_tag_list import InputTagList
157
+ from supervisely.app.widgets.deploy_model.deploy_model import DeployModel
158
+ from supervisely.app.widgets.dropdown_checkbox_selector.dropdown_checkbox_selector import (
159
+ DropdownCheckboxSelector,
160
+ )
161
+ from supervisely.app.widgets.ecosystem_model_selector.ecosystem_model_selector import (
162
+ EcosystemModelSelector,
163
+ )
164
+ from supervisely.app.widgets.heatmap.heatmap import Heatmap
@@ -5,6 +5,7 @@
5
5
  :group-id="data.{{{ widget.widget_id }}}.teamId"
6
6
  :options="state.{{{ widget.widget_id }}}.options"
7
7
  :is-community="data.{{{ widget.widget_id }}}.isCommunity"
8
+ :disabled="data.{{{ widget.widget_id }}}.disabled"
8
9
  {% if widget._changes_handled == true %}
9
10
  @input="state.{{{ widget.widget_id }}}.agentId = $event; post('/{{{ widget.widget_id }}}/value_changed')"
10
11
  {% else %}