supervisely 6.73.438__py3-none-any.whl → 6.73.513__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 (203) hide show
  1. supervisely/__init__.py +137 -1
  2. supervisely/_utils.py +81 -0
  3. supervisely/annotation/annotation.py +8 -2
  4. supervisely/annotation/json_geometries_map.py +14 -11
  5. supervisely/annotation/label.py +80 -3
  6. supervisely/api/annotation_api.py +14 -11
  7. supervisely/api/api.py +59 -38
  8. supervisely/api/app_api.py +11 -2
  9. supervisely/api/dataset_api.py +74 -12
  10. supervisely/api/entities_collection_api.py +10 -0
  11. supervisely/api/entity_annotation/figure_api.py +52 -4
  12. supervisely/api/entity_annotation/object_api.py +3 -3
  13. supervisely/api/entity_annotation/tag_api.py +63 -12
  14. supervisely/api/guides_api.py +210 -0
  15. supervisely/api/image_api.py +72 -1
  16. supervisely/api/labeling_job_api.py +83 -1
  17. supervisely/api/labeling_queue_api.py +33 -7
  18. supervisely/api/module_api.py +9 -0
  19. supervisely/api/project_api.py +71 -26
  20. supervisely/api/storage_api.py +3 -1
  21. supervisely/api/task_api.py +13 -2
  22. supervisely/api/team_api.py +4 -3
  23. supervisely/api/video/video_annotation_api.py +119 -3
  24. supervisely/api/video/video_api.py +65 -14
  25. supervisely/api/video/video_figure_api.py +24 -11
  26. supervisely/app/__init__.py +1 -1
  27. supervisely/app/content.py +23 -7
  28. supervisely/app/development/development.py +18 -2
  29. supervisely/app/fastapi/__init__.py +1 -0
  30. supervisely/app/fastapi/custom_static_files.py +1 -1
  31. supervisely/app/fastapi/multi_user.py +105 -0
  32. supervisely/app/fastapi/subapp.py +88 -42
  33. supervisely/app/fastapi/websocket.py +77 -9
  34. supervisely/app/singleton.py +21 -0
  35. supervisely/app/v1/app_service.py +18 -2
  36. supervisely/app/v1/constants.py +7 -1
  37. supervisely/app/widgets/__init__.py +6 -0
  38. supervisely/app/widgets/activity_feed/__init__.py +0 -0
  39. supervisely/app/widgets/activity_feed/activity_feed.py +239 -0
  40. supervisely/app/widgets/activity_feed/style.css +78 -0
  41. supervisely/app/widgets/activity_feed/template.html +22 -0
  42. supervisely/app/widgets/card/card.py +20 -0
  43. supervisely/app/widgets/classes_list_selector/classes_list_selector.py +121 -9
  44. supervisely/app/widgets/classes_list_selector/template.html +60 -93
  45. supervisely/app/widgets/classes_mapping/classes_mapping.py +13 -12
  46. supervisely/app/widgets/classes_table/classes_table.py +1 -0
  47. supervisely/app/widgets/deploy_model/deploy_model.py +56 -35
  48. supervisely/app/widgets/dialog/dialog.py +12 -0
  49. supervisely/app/widgets/dialog/template.html +2 -1
  50. supervisely/app/widgets/ecosystem_model_selector/ecosystem_model_selector.py +1 -1
  51. supervisely/app/widgets/experiment_selector/experiment_selector.py +8 -0
  52. supervisely/app/widgets/fast_table/fast_table.py +184 -60
  53. supervisely/app/widgets/fast_table/template.html +1 -1
  54. supervisely/app/widgets/heatmap/__init__.py +0 -0
  55. supervisely/app/widgets/heatmap/heatmap.py +564 -0
  56. supervisely/app/widgets/heatmap/script.js +533 -0
  57. supervisely/app/widgets/heatmap/style.css +233 -0
  58. supervisely/app/widgets/heatmap/template.html +21 -0
  59. supervisely/app/widgets/modal/__init__.py +0 -0
  60. supervisely/app/widgets/modal/modal.py +198 -0
  61. supervisely/app/widgets/modal/template.html +10 -0
  62. supervisely/app/widgets/object_class_view/object_class_view.py +3 -0
  63. supervisely/app/widgets/radio_tabs/radio_tabs.py +18 -2
  64. supervisely/app/widgets/radio_tabs/template.html +1 -0
  65. supervisely/app/widgets/select/select.py +6 -3
  66. supervisely/app/widgets/select_class/__init__.py +0 -0
  67. supervisely/app/widgets/select_class/select_class.py +363 -0
  68. supervisely/app/widgets/select_class/template.html +50 -0
  69. supervisely/app/widgets/select_cuda/select_cuda.py +22 -0
  70. supervisely/app/widgets/select_dataset_tree/select_dataset_tree.py +65 -7
  71. supervisely/app/widgets/select_tag/__init__.py +0 -0
  72. supervisely/app/widgets/select_tag/select_tag.py +352 -0
  73. supervisely/app/widgets/select_tag/template.html +64 -0
  74. supervisely/app/widgets/select_team/select_team.py +37 -4
  75. supervisely/app/widgets/select_team/template.html +4 -5
  76. supervisely/app/widgets/select_user/__init__.py +0 -0
  77. supervisely/app/widgets/select_user/select_user.py +270 -0
  78. supervisely/app/widgets/select_user/template.html +13 -0
  79. supervisely/app/widgets/select_workspace/select_workspace.py +59 -10
  80. supervisely/app/widgets/select_workspace/template.html +9 -12
  81. supervisely/app/widgets/table/table.py +68 -13
  82. supervisely/app/widgets/tree_select/tree_select.py +2 -0
  83. supervisely/aug/aug.py +6 -2
  84. supervisely/convert/base_converter.py +1 -0
  85. supervisely/convert/converter.py +2 -2
  86. supervisely/convert/image/csv/csv_converter.py +24 -15
  87. supervisely/convert/image/image_converter.py +3 -1
  88. supervisely/convert/image/image_helper.py +48 -4
  89. supervisely/convert/image/label_studio/label_studio_converter.py +2 -0
  90. supervisely/convert/image/medical2d/medical2d_helper.py +2 -24
  91. supervisely/convert/image/multispectral/multispectral_converter.py +6 -0
  92. supervisely/convert/image/pascal_voc/pascal_voc_converter.py +8 -5
  93. supervisely/convert/image/pascal_voc/pascal_voc_helper.py +7 -0
  94. supervisely/convert/pointcloud/kitti_3d/kitti_3d_converter.py +33 -3
  95. supervisely/convert/pointcloud/kitti_3d/kitti_3d_helper.py +12 -5
  96. supervisely/convert/pointcloud/las/las_converter.py +13 -1
  97. supervisely/convert/pointcloud/las/las_helper.py +110 -11
  98. supervisely/convert/pointcloud/nuscenes_conv/nuscenes_converter.py +27 -16
  99. supervisely/convert/pointcloud/pointcloud_converter.py +91 -3
  100. supervisely/convert/pointcloud_episodes/nuscenes_conv/nuscenes_converter.py +58 -22
  101. supervisely/convert/pointcloud_episodes/nuscenes_conv/nuscenes_helper.py +21 -47
  102. supervisely/convert/video/__init__.py +1 -0
  103. supervisely/convert/video/multi_view/__init__.py +0 -0
  104. supervisely/convert/video/multi_view/multi_view.py +543 -0
  105. supervisely/convert/video/sly/sly_video_converter.py +359 -3
  106. supervisely/convert/video/video_converter.py +24 -4
  107. supervisely/convert/volume/dicom/dicom_converter.py +13 -5
  108. supervisely/convert/volume/dicom/dicom_helper.py +30 -18
  109. supervisely/geometry/constants.py +1 -0
  110. supervisely/geometry/geometry.py +4 -0
  111. supervisely/geometry/helpers.py +5 -1
  112. supervisely/geometry/oriented_bbox.py +676 -0
  113. supervisely/geometry/polyline_3d.py +110 -0
  114. supervisely/geometry/rectangle.py +2 -1
  115. supervisely/io/env.py +76 -1
  116. supervisely/io/fs.py +21 -0
  117. supervisely/nn/benchmark/base_evaluator.py +104 -11
  118. supervisely/nn/benchmark/instance_segmentation/evaluator.py +1 -8
  119. supervisely/nn/benchmark/object_detection/evaluator.py +20 -4
  120. supervisely/nn/benchmark/object_detection/vis_metrics/pr_curve.py +10 -5
  121. supervisely/nn/benchmark/semantic_segmentation/evaluator.py +34 -16
  122. supervisely/nn/benchmark/semantic_segmentation/vis_metrics/confusion_matrix.py +1 -1
  123. supervisely/nn/benchmark/semantic_segmentation/vis_metrics/frequently_confused.py +1 -1
  124. supervisely/nn/benchmark/semantic_segmentation/vis_metrics/overview.py +1 -1
  125. supervisely/nn/benchmark/visualization/evaluation_result.py +66 -4
  126. supervisely/nn/inference/cache.py +43 -18
  127. supervisely/nn/inference/gui/serving_gui_template.py +5 -2
  128. supervisely/nn/inference/inference.py +916 -222
  129. supervisely/nn/inference/inference_request.py +55 -10
  130. supervisely/nn/inference/predict_app/gui/classes_selector.py +83 -12
  131. supervisely/nn/inference/predict_app/gui/gui.py +676 -488
  132. supervisely/nn/inference/predict_app/gui/input_selector.py +205 -26
  133. supervisely/nn/inference/predict_app/gui/model_selector.py +2 -4
  134. supervisely/nn/inference/predict_app/gui/output_selector.py +46 -6
  135. supervisely/nn/inference/predict_app/gui/settings_selector.py +756 -59
  136. supervisely/nn/inference/predict_app/gui/tags_selector.py +1 -1
  137. supervisely/nn/inference/predict_app/gui/utils.py +236 -119
  138. supervisely/nn/inference/predict_app/predict_app.py +2 -2
  139. supervisely/nn/inference/session.py +43 -35
  140. supervisely/nn/inference/tracking/bbox_tracking.py +118 -35
  141. supervisely/nn/inference/tracking/point_tracking.py +5 -1
  142. supervisely/nn/inference/tracking/tracker_interface.py +10 -1
  143. supervisely/nn/inference/uploader.py +139 -12
  144. supervisely/nn/live_training/__init__.py +7 -0
  145. supervisely/nn/live_training/api_server.py +111 -0
  146. supervisely/nn/live_training/artifacts_utils.py +243 -0
  147. supervisely/nn/live_training/checkpoint_utils.py +229 -0
  148. supervisely/nn/live_training/dynamic_sampler.py +44 -0
  149. supervisely/nn/live_training/helpers.py +14 -0
  150. supervisely/nn/live_training/incremental_dataset.py +146 -0
  151. supervisely/nn/live_training/live_training.py +497 -0
  152. supervisely/nn/live_training/loss_plateau_detector.py +111 -0
  153. supervisely/nn/live_training/request_queue.py +52 -0
  154. supervisely/nn/model/model_api.py +9 -0
  155. supervisely/nn/model/prediction.py +2 -1
  156. supervisely/nn/model/prediction_session.py +26 -14
  157. supervisely/nn/prediction_dto.py +19 -1
  158. supervisely/nn/tracker/base_tracker.py +11 -1
  159. supervisely/nn/tracker/botsort/botsort_config.yaml +0 -1
  160. supervisely/nn/tracker/botsort/tracker/mc_bot_sort.py +7 -4
  161. supervisely/nn/tracker/botsort_tracker.py +94 -65
  162. supervisely/nn/tracker/utils.py +4 -5
  163. supervisely/nn/tracker/visualize.py +93 -93
  164. supervisely/nn/training/gui/classes_selector.py +16 -1
  165. supervisely/nn/training/gui/train_val_splits_selector.py +52 -31
  166. supervisely/nn/training/train_app.py +46 -31
  167. supervisely/project/data_version.py +115 -51
  168. supervisely/project/download.py +1 -1
  169. supervisely/project/pointcloud_episode_project.py +37 -8
  170. supervisely/project/pointcloud_project.py +30 -2
  171. supervisely/project/project.py +14 -2
  172. supervisely/project/project_meta.py +27 -1
  173. supervisely/project/project_settings.py +32 -18
  174. supervisely/project/versioning/__init__.py +1 -0
  175. supervisely/project/versioning/common.py +20 -0
  176. supervisely/project/versioning/schema_fields.py +35 -0
  177. supervisely/project/versioning/video_schema.py +221 -0
  178. supervisely/project/versioning/volume_schema.py +87 -0
  179. supervisely/project/video_project.py +717 -15
  180. supervisely/project/volume_project.py +623 -5
  181. supervisely/template/experiment/experiment.html.jinja +4 -4
  182. supervisely/template/experiment/experiment_generator.py +14 -21
  183. supervisely/template/live_training/__init__.py +0 -0
  184. supervisely/template/live_training/header.html.jinja +96 -0
  185. supervisely/template/live_training/live_training.html.jinja +51 -0
  186. supervisely/template/live_training/live_training_generator.py +464 -0
  187. supervisely/template/live_training/sly-style.css +402 -0
  188. supervisely/template/live_training/template.html.jinja +18 -0
  189. supervisely/versions.json +28 -26
  190. supervisely/video/sampling.py +39 -20
  191. supervisely/video/video.py +41 -12
  192. supervisely/video_annotation/video_figure.py +38 -4
  193. supervisely/video_annotation/video_object.py +29 -4
  194. supervisely/volume/stl_converter.py +2 -0
  195. supervisely/worker_api/agent_rpc.py +24 -1
  196. supervisely/worker_api/rpc_servicer.py +31 -7
  197. {supervisely-6.73.438.dist-info → supervisely-6.73.513.dist-info}/METADATA +58 -40
  198. {supervisely-6.73.438.dist-info → supervisely-6.73.513.dist-info}/RECORD +203 -155
  199. {supervisely-6.73.438.dist-info → supervisely-6.73.513.dist-info}/WHEEL +1 -1
  200. supervisely_lib/__init__.py +6 -1
  201. {supervisely-6.73.438.dist-info → supervisely-6.73.513.dist-info}/entry_points.txt +0 -0
  202. {supervisely-6.73.438.dist-info → supervisely-6.73.513.dist-info/licenses}/LICENSE +0 -0
  203. {supervisely-6.73.438.dist-info → supervisely-6.73.513.dist-info}/top_level.txt +0 -0
@@ -10,7 +10,7 @@ from supervisely.api.module_api import ApiField
10
10
  from supervisely.geometry.geometry import Geometry
11
11
  from supervisely.video_annotation.key_id_map import KeyIdMap
12
12
  from supervisely.video_annotation.video_figure import VideoFigure
13
-
13
+ from supervisely.annotation.label import LabelingStatus
14
14
 
15
15
  class VideoFigureApi(FigureApi):
16
16
  """
@@ -26,6 +26,7 @@ class VideoFigureApi(FigureApi):
26
26
  geometry_type: str,
27
27
  track_id: Optional[int] = None,
28
28
  meta: Optional[dict] = None,
29
+ status: Optional[LabelingStatus] = None,
29
30
  ) -> int:
30
31
  """
31
32
  Create new VideoFigure for given frame in given video ID.
@@ -42,6 +43,10 @@ class VideoFigureApi(FigureApi):
42
43
  :type geometry_type: str
43
44
  :param track_id: int, optional.
44
45
  :type track_id: int, optional
46
+ :param meta: Meta data for VideoFigure.
47
+ :type meta: dict, optional
48
+ :param status: Labeling status. Specifies if the VideoFigure was created by NN model, manually or created by NN and then manually corrected.
49
+ :type status: LabelingStatus, optional
45
50
  :return: New figure ID
46
51
  :rtype: :class:`int`
47
52
  :Usage example:
@@ -64,13 +69,16 @@ class VideoFigureApi(FigureApi):
64
69
  """
65
70
  if meta is None:
66
71
  meta = {}
72
+ meta = {**(meta or {}), ApiField.FRAME: frame_index}
73
+
67
74
  return super().create(
68
75
  video_id,
69
76
  object_id,
70
- {**meta, ApiField.FRAME: frame_index},
77
+ meta,
71
78
  geometry_json,
72
79
  geometry_type,
73
80
  track_id,
81
+ status=status,
74
82
  )
75
83
 
76
84
  def append_bulk(self, video_id: int, figures: List[VideoFigure], key_id_map: KeyIdMap) -> None:
@@ -115,13 +123,15 @@ class VideoFigureApi(FigureApi):
115
123
 
116
124
  self._append_bulk(video_id, figures_json, keys, key_id_map)
117
125
 
118
- def update(self, figure_id: int, geometry: Geometry) -> None:
126
+ def update(self, figure_id: int, geometry: Geometry, status: Optional[LabelingStatus] = None) -> None:
119
127
  """Updates figure feometry with given ID in Supervisely with new Geometry object.
120
128
 
121
129
  :param figure_id: ID of the figure to update
122
130
  :type figure_id: int
123
131
  :param geometry: Supervisely Gepmetry object
124
132
  :type geometry: Geometry
133
+ :param status: Labeling status. Specifies if the VideoFigure was created by NN model, manually or created by NN and then manually corrected.
134
+ :type status: LabelingStatus, optional
125
135
  :Usage example:
126
136
 
127
137
  .. code-block:: python
@@ -141,13 +151,17 @@ class VideoFigureApi(FigureApi):
141
151
 
142
152
  api.video.figure.update(figure_id, new_geometry)
143
153
  """
144
- self._api.post(
145
- "figures.editInfo",
146
- {
147
- ApiField.ID: figure_id,
148
- ApiField.GEOMETRY: geometry.to_json(),
149
- },
150
- )
154
+ payload = {
155
+ ApiField.ID: figure_id,
156
+ ApiField.GEOMETRY: geometry.to_json(),
157
+ }
158
+
159
+ if status is not None:
160
+ nn_created,nn_updated = LabelingStatus.to_flags(status)
161
+ payload[ApiField.NN_CREATED] = nn_created
162
+ payload[ApiField.NN_UPDATED] = nn_updated
163
+
164
+ self._api.post("figures.editInfo", payload)
151
165
 
152
166
  def download(
153
167
  self, dataset_id: int, video_ids: List[int] = None, skip_geometry: bool = False, **kwargs
@@ -161,7 +175,6 @@ class VideoFigureApi(FigureApi):
161
175
  :type video_ids: List[int], optional
162
176
  :param skip_geometry: Skip the download of figure geometry. May be useful for a significant api request speed increase in the large datasets.
163
177
  :type skip_geometry: bool
164
-
165
178
  :return: A dictionary where keys are video IDs and values are lists of figures.
166
179
  :rtype: :class: `Dict[int, List[FigureInfo]]`
167
180
  """
@@ -1,7 +1,7 @@
1
1
  from fastapi import FastAPI
2
2
  from supervisely.app.content import StateJson, DataJson
3
3
  from supervisely.app.content import get_data_dir, get_synced_data_dir
4
- from supervisely.app.fastapi.subapp import call_on_autostart
4
+ from supervisely.app.fastapi.subapp import call_on_autostart, session_user_api
5
5
  import supervisely.app.fastapi as fastapi
6
6
  import supervisely.app.widgets as widgets
7
7
  import supervisely.app.development as development
@@ -11,12 +11,14 @@ import threading
11
11
  import time
12
12
  import traceback
13
13
  from concurrent.futures import ThreadPoolExecutor
14
+ from typing import Optional, Union
14
15
 
15
16
  import jsonpatch
16
17
  from fastapi import Request
17
18
 
18
19
  from supervisely._utils import is_production
19
20
  from supervisely.api.api import Api
21
+ import supervisely.app.fastapi.multi_user as multi_user
20
22
  from supervisely.app.fastapi import run_sync
21
23
  from supervisely.app.fastapi.websocket import WebsocketManager
22
24
  from supervisely.app.singleton import Singleton
@@ -109,16 +111,29 @@ class _PatchableJson(dict):
109
111
  patch.apply(self._last, in_place=True)
110
112
  self._last = copy.deepcopy(self._last)
111
113
 
112
- async def synchronize_changes(self):
114
+ async def synchronize_changes(self, user_id: Optional[Union[int, str]] = None):
113
115
  patch = self._get_patch()
114
- await self._apply_patch(patch)
115
- await self._ws.broadcast(self.get_changes(patch))
116
+ if user_id is not None:
117
+ async with multi_user.async_session_context(user_id):
118
+ await self._apply_patch(patch)
119
+ await self._ws.broadcast(
120
+ self.get_changes(patch), user_id=user_id
121
+ )
122
+ else:
123
+ await self._apply_patch(patch)
124
+ await self._ws.broadcast(self.get_changes(patch), user_id=user_id)
116
125
 
117
126
  async def send_changes_async(self):
118
- await self.synchronize_changes()
127
+ user_id = None
128
+ if sly_env.is_multiuser_mode_enabled():
129
+ user_id = sly_env.user_from_multiuser_app()
130
+ await self.synchronize_changes(user_id=user_id)
119
131
 
120
132
  def send_changes(self):
121
- run_sync(self.synchronize_changes())
133
+ user_id = None
134
+ if sly_env.is_multiuser_mode_enabled():
135
+ user_id = sly_env.user_from_multiuser_app()
136
+ run_sync(self.synchronize_changes(user_id=user_id))
122
137
 
123
138
  def raise_for_key(self, key: str):
124
139
  if key in self:
@@ -139,7 +154,7 @@ class StateJson(_PatchableJson, metaclass=Singleton):
139
154
  await StateJson._replace_global(dict(self))
140
155
 
141
156
  @classmethod
142
- async def from_request(cls, request: Request) -> StateJson:
157
+ async def from_request(cls, request: Request, local: bool = True) -> StateJson:
143
158
  if "application/json" not in request.headers.get("Content-Type", ""):
144
159
  return None
145
160
  content = await request.json()
@@ -149,7 +164,8 @@ class StateJson(_PatchableJson, metaclass=Singleton):
149
164
  # TODO: should we always replace STATE with {}?
150
165
  d = content.get(Field.STATE, {})
151
166
  await cls._replace_global(d)
152
- return cls(d, __local__=True)
167
+
168
+ return cls(d, __local__=local)
153
169
 
154
170
  @classmethod
155
171
  async def _replace_global(cls, d: dict):
@@ -156,7 +156,10 @@ def supervisely_vpn_network(
156
156
 
157
157
 
158
158
  def create_debug_task(
159
- team_id: int = None, port: int = 8000, update_status: bool = True
159
+ team_id: int = None,
160
+ port: int = 8000,
161
+ update_status: bool = True,
162
+ project_id: Optional[int] = None,
160
163
  ) -> Dict[str, Any]:
161
164
  """Gets or creates a debug task for the current user.
162
165
 
@@ -167,6 +170,8 @@ def create_debug_task(
167
170
  :type port: int
168
171
  :param update_status: If True, the task status will be updated to STARTED.
169
172
  :type update_status: bool
173
+ :param project_id: Project ID to filter existing debug tasks. Creates a new task if no match is found. Default is None.
174
+ :type project_id: Optional[int]
170
175
  :return: The task details.
171
176
  :rtype: Dict[str, Any]
172
177
  """
@@ -189,6 +194,10 @@ def create_debug_task(
189
194
  if (session.details["meta"].get("redirectRequests") == redirect_requests) and (
190
195
  session.details["status"] in [str(api.app.Status.QUEUED), str(api.app.Status.STARTED)]
191
196
  ):
197
+ if project_id is not None:
198
+ state = session.details["meta"].get("params", {}).get("state", {})
199
+ if state.get("slyProjectId") != project_id:
200
+ continue # project_id not set in state, skip this session
192
201
  task = session.details
193
202
  if "id" not in task:
194
203
  task["id"] = task["taskId"]
@@ -196,6 +205,7 @@ def create_debug_task(
196
205
  break
197
206
  workspaces = api.workspace.get_list(team_id)
198
207
  if task is None:
208
+ params = {"state": {"slyProjectId": project_id}} if project_id is not None else None
199
209
  task = api.task.start(
200
210
  agent_id=None,
201
211
  module_id=module_id,
@@ -203,6 +213,7 @@ def create_debug_task(
203
213
  task_name=session_name,
204
214
  redirect_requests=redirect_requests,
205
215
  proxy_keep_url=False, # to ignore /net/<token>/endpoint
216
+ params=params,
206
217
  )
207
218
  if type(task) is list:
208
219
  task = task[0]
@@ -222,6 +233,7 @@ def enable_advanced_debug(
222
233
  vpn_action: Literal["up", "down"] = "up",
223
234
  vpn_raise_on_error: bool = True,
224
235
  only_for_development: bool = True,
236
+ project_id: Optional[int] = None,
225
237
  ) -> Optional[int]:
226
238
  """Enables advanced debugging for the app.
227
239
  At first, it establishes a WireGuard VPN connection to the Supervisely network.
@@ -244,6 +256,8 @@ def enable_advanced_debug(
244
256
  :param only_for_development: If True, the debugging will be started only if the app is running in development mode.
245
257
  It's not recommended to set this parameter to False in production environments.
246
258
  :type only_for_development: bool
259
+ :param project_id: Project ID to filter existing debug tasks. Creates a new task if no match is found. Default is None.
260
+ :type project_id: Optional[int]
247
261
  :return: The task ID of the debug task or None if the debugging was not started.
248
262
  :rtype: Optional[int]
249
263
 
@@ -285,7 +299,9 @@ def enable_advanced_debug(
285
299
  )
286
300
 
287
301
  supervisely_vpn_network(action=vpn_action, raise_on_error=vpn_raise_on_error)
288
- task = create_debug_task(team_id=team_id, port=port, update_status=update_status)
302
+ task = create_debug_task(
303
+ team_id=team_id, port=port, update_status=update_status, project_id=project_id
304
+ )
289
305
  task_id = task.get("id", None)
290
306
 
291
307
  logger.debug(
@@ -5,6 +5,7 @@ from supervisely.app.fastapi.subapp import (
5
5
  Application,
6
6
  get_name_from_env,
7
7
  _MainServer,
8
+ session_user_api,
8
9
  )
9
10
  from supervisely.app.fastapi.templating import Jinja2Templates
10
11
  from supervisely.app.fastapi.websocket import WebsocketManager
@@ -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,105 @@
1
+ import hashlib
2
+ from contextlib import contextmanager, asynccontextmanager
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
+ def _session_context_impl(user_id: Optional[Union[int, str]]):
64
+ """Internal implementation for session context."""
65
+ if not sly_env.is_multiuser_mode_enabled() or user_id is None:
66
+ return None
67
+ return sly_env.set_user_for_multiuser_app(user_id)
68
+
69
+ @contextmanager
70
+ def session_context(user_id: Optional[Union[int, str]]):
71
+ """
72
+ Context manager to set and reset user context for multiuser applications.
73
+ 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).
74
+ """
75
+ token = _session_context_impl(user_id)
76
+ try:
77
+ yield
78
+ finally:
79
+ if token is not None:
80
+ sly_env.reset_user_for_multiuser_app(token)
81
+
82
+ @asynccontextmanager
83
+ async def async_session_context(user_id: Optional[Union[int, str]]):
84
+ """
85
+ Asynchronous context manager to set and reset user context for multiuser applications.
86
+ Call this at the beginning of an async request handling to ensure the correct user context is set in environment variables (`supervisely_multiuser_app_user_id` ContextVar).
87
+ """
88
+ token = _session_context_impl(user_id)
89
+ try:
90
+ yield
91
+ finally:
92
+ if token is not None:
93
+ sly_env.reset_user_for_multiuser_app(token)
94
+
95
+
96
+ def remember_cookie(request: Request, user_id: Optional[Union[int, str]]):
97
+ """
98
+ Remember user cookie for the given user ID. This is used to associate WebSocket connections with users in multiuser applications based on cookies.
99
+ Allows WebSocket connections to be correctly routed to the appropriate user.
100
+ """
101
+ if not sly_env.is_multiuser_mode_enabled() or user_id is None:
102
+ return
103
+ cookie_header = request.headers.get("cookie")
104
+ if cookie_header:
105
+ WebsocketManager().remember_user_cookie(cookie_header, user_id)
@@ -1,23 +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
7
  import time
7
- from contextlib import suppress
8
+ from contextlib import contextmanager, suppress
8
9
  from contextvars import ContextVar
9
10
  from functools import wraps
10
11
  from pathlib import Path
11
12
  from threading import Event as ThreadingEvent
12
13
  from threading import Thread
13
14
  from time import sleep
14
- from typing import TYPE_CHECKING, Any, Callable, Dict, List, Optional
15
+ from typing import TYPE_CHECKING, Any, Callable, Dict, List, Optional, Union
15
16
 
16
17
  import arel
17
18
  import jinja2
18
19
  import numpy as np
19
20
  import psutil
20
21
  from async_asgi_testclient import TestClient
22
+ from cachetools import TTLCache
21
23
  from fastapi import (
22
24
  Depends,
23
25
  FastAPI,
@@ -32,6 +34,7 @@ from fastapi.responses import JSONResponse
32
34
  from fastapi.routing import APIRouter
33
35
  from fastapi.staticfiles import StaticFiles
34
36
 
37
+ import supervisely.app.fastapi.multi_user as multi_user
35
38
  import supervisely.io.env as sly_env
36
39
  from supervisely._utils import (
37
40
  is_debug_with_sly_net,
@@ -68,6 +71,10 @@ HEALTH_ENDPOINTS = ["/health", "/is_ready"]
68
71
  # Context variable for response time
69
72
  response_time_ctx: ContextVar[float] = ContextVar("response_time", default=None)
70
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
+
77
+
71
78
  class ReadyzFilter(logging.Filter):
72
79
  def filter(self, record):
73
80
  if "/readyz" in record.getMessage() or "/livez" in record.getMessage():
@@ -623,18 +630,30 @@ def create(
623
630
  shutdown(process_id, before_shutdown_callbacks)
624
631
 
625
632
  if headless is False:
626
-
627
633
  @app.post("/data")
628
634
  async def send_data(request: Request):
629
- data = DataJson()
630
- 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))
631
644
  return response
632
645
 
633
646
  @app.post("/state")
634
647
  async def send_state(request: Request):
635
- state = StateJson()
636
-
637
- 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))
638
657
  gettrace = getattr(sys, "gettrace", None)
639
658
  if (gettrace is not None and gettrace()) or is_development():
640
659
  response.headers["x-debug-mode"] = "1"
@@ -813,41 +832,59 @@ def _init(
813
832
  async def get_state_from_request(request: Request, call_next):
814
833
  # Start timer for response time measurement
815
834
  start_time = time.perf_counter()
816
- if headless is False:
817
- await StateJson.from_request(request)
818
-
819
- if not ("application/json" not in request.headers.get("Content-Type", "")):
820
- # {'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'}
821
- content = await request.json()
822
-
823
- request.state.context = content.get("context")
824
- request.state.state = content.get("state")
825
- request.state.api_token = content.get(
826
- "api_token",
827
- (
828
- request.state.context.get("apiToken")
829
- if request.state.context is not None
830
- else None
831
- ),
832
- )
833
- # logger.debug(f"middleware request api_token {request.state.api_token}")
834
- request.state.server_address = content.get(
835
- "server_address", sly_env.server_address(raise_not_found=False)
836
- )
837
- # request.state.server_address = sly_env.server_address(raise_not_found=False)
838
- # logger.debug(f"middleware request server_address {request.state.server_address}")
839
- # logger.debug(f"middleware request context {request.state.context}")
840
- # logger.debug(f"middleware request state {request.state.state}")
841
- if request.state.server_address is not None and request.state.api_token is not None:
842
- request.state.api = Api(request.state.server_address, request.state.api_token)
843
- else:
844
- request.state.api = None
845
835
 
846
- try:
847
- response = await call_next(request)
848
- except Exception as exc:
849
- need_to_handle_error = is_production()
850
- response = await process_server_error(request, exc, need_to_handle_error)
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
865
+
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)
851
888
  # Calculate response time and set it for uvicorn logger in ms
852
889
  elapsed_ms = round((time.perf_counter() - start_time) * 1000)
853
890
  response_time_ctx.set(elapsed_ms)
@@ -1277,3 +1314,12 @@ def call_on_autostart(
1277
1314
 
1278
1315
  def get_name_from_env(default="Supervisely App"):
1279
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)
@@ -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)