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.
- supervisely/__init__.py +137 -1
- supervisely/_utils.py +81 -0
- supervisely/annotation/annotation.py +8 -2
- supervisely/annotation/json_geometries_map.py +14 -11
- supervisely/annotation/label.py +80 -3
- supervisely/api/annotation_api.py +14 -11
- supervisely/api/api.py +59 -38
- supervisely/api/app_api.py +11 -2
- supervisely/api/dataset_api.py +74 -12
- supervisely/api/entities_collection_api.py +10 -0
- supervisely/api/entity_annotation/figure_api.py +52 -4
- supervisely/api/entity_annotation/object_api.py +3 -3
- supervisely/api/entity_annotation/tag_api.py +63 -12
- supervisely/api/guides_api.py +210 -0
- supervisely/api/image_api.py +72 -1
- supervisely/api/labeling_job_api.py +83 -1
- supervisely/api/labeling_queue_api.py +33 -7
- supervisely/api/module_api.py +9 -0
- supervisely/api/project_api.py +71 -26
- supervisely/api/storage_api.py +3 -1
- supervisely/api/task_api.py +13 -2
- supervisely/api/team_api.py +4 -3
- supervisely/api/video/video_annotation_api.py +119 -3
- supervisely/api/video/video_api.py +65 -14
- supervisely/api/video/video_figure_api.py +24 -11
- supervisely/app/__init__.py +1 -1
- supervisely/app/content.py +23 -7
- supervisely/app/development/development.py +18 -2
- supervisely/app/fastapi/__init__.py +1 -0
- supervisely/app/fastapi/custom_static_files.py +1 -1
- supervisely/app/fastapi/multi_user.py +105 -0
- supervisely/app/fastapi/subapp.py +88 -42
- supervisely/app/fastapi/websocket.py +77 -9
- supervisely/app/singleton.py +21 -0
- supervisely/app/v1/app_service.py +18 -2
- supervisely/app/v1/constants.py +7 -1
- supervisely/app/widgets/__init__.py +6 -0
- supervisely/app/widgets/activity_feed/__init__.py +0 -0
- supervisely/app/widgets/activity_feed/activity_feed.py +239 -0
- supervisely/app/widgets/activity_feed/style.css +78 -0
- supervisely/app/widgets/activity_feed/template.html +22 -0
- supervisely/app/widgets/card/card.py +20 -0
- supervisely/app/widgets/classes_list_selector/classes_list_selector.py +121 -9
- supervisely/app/widgets/classes_list_selector/template.html +60 -93
- supervisely/app/widgets/classes_mapping/classes_mapping.py +13 -12
- supervisely/app/widgets/classes_table/classes_table.py +1 -0
- supervisely/app/widgets/deploy_model/deploy_model.py +56 -35
- supervisely/app/widgets/dialog/dialog.py +12 -0
- supervisely/app/widgets/dialog/template.html +2 -1
- supervisely/app/widgets/ecosystem_model_selector/ecosystem_model_selector.py +1 -1
- supervisely/app/widgets/experiment_selector/experiment_selector.py +8 -0
- supervisely/app/widgets/fast_table/fast_table.py +184 -60
- supervisely/app/widgets/fast_table/template.html +1 -1
- supervisely/app/widgets/heatmap/__init__.py +0 -0
- supervisely/app/widgets/heatmap/heatmap.py +564 -0
- supervisely/app/widgets/heatmap/script.js +533 -0
- supervisely/app/widgets/heatmap/style.css +233 -0
- supervisely/app/widgets/heatmap/template.html +21 -0
- supervisely/app/widgets/modal/__init__.py +0 -0
- supervisely/app/widgets/modal/modal.py +198 -0
- supervisely/app/widgets/modal/template.html +10 -0
- supervisely/app/widgets/object_class_view/object_class_view.py +3 -0
- supervisely/app/widgets/radio_tabs/radio_tabs.py +18 -2
- supervisely/app/widgets/radio_tabs/template.html +1 -0
- supervisely/app/widgets/select/select.py +6 -3
- supervisely/app/widgets/select_class/__init__.py +0 -0
- supervisely/app/widgets/select_class/select_class.py +363 -0
- supervisely/app/widgets/select_class/template.html +50 -0
- supervisely/app/widgets/select_cuda/select_cuda.py +22 -0
- supervisely/app/widgets/select_dataset_tree/select_dataset_tree.py +65 -7
- supervisely/app/widgets/select_tag/__init__.py +0 -0
- supervisely/app/widgets/select_tag/select_tag.py +352 -0
- supervisely/app/widgets/select_tag/template.html +64 -0
- supervisely/app/widgets/select_team/select_team.py +37 -4
- supervisely/app/widgets/select_team/template.html +4 -5
- supervisely/app/widgets/select_user/__init__.py +0 -0
- supervisely/app/widgets/select_user/select_user.py +270 -0
- supervisely/app/widgets/select_user/template.html +13 -0
- supervisely/app/widgets/select_workspace/select_workspace.py +59 -10
- supervisely/app/widgets/select_workspace/template.html +9 -12
- supervisely/app/widgets/table/table.py +68 -13
- supervisely/app/widgets/tree_select/tree_select.py +2 -0
- supervisely/aug/aug.py +6 -2
- supervisely/convert/base_converter.py +1 -0
- supervisely/convert/converter.py +2 -2
- supervisely/convert/image/csv/csv_converter.py +24 -15
- supervisely/convert/image/image_converter.py +3 -1
- supervisely/convert/image/image_helper.py +48 -4
- supervisely/convert/image/label_studio/label_studio_converter.py +2 -0
- supervisely/convert/image/medical2d/medical2d_helper.py +2 -24
- supervisely/convert/image/multispectral/multispectral_converter.py +6 -0
- supervisely/convert/image/pascal_voc/pascal_voc_converter.py +8 -5
- supervisely/convert/image/pascal_voc/pascal_voc_helper.py +7 -0
- supervisely/convert/pointcloud/kitti_3d/kitti_3d_converter.py +33 -3
- supervisely/convert/pointcloud/kitti_3d/kitti_3d_helper.py +12 -5
- supervisely/convert/pointcloud/las/las_converter.py +13 -1
- supervisely/convert/pointcloud/las/las_helper.py +110 -11
- supervisely/convert/pointcloud/nuscenes_conv/nuscenes_converter.py +27 -16
- supervisely/convert/pointcloud/pointcloud_converter.py +91 -3
- supervisely/convert/pointcloud_episodes/nuscenes_conv/nuscenes_converter.py +58 -22
- supervisely/convert/pointcloud_episodes/nuscenes_conv/nuscenes_helper.py +21 -47
- supervisely/convert/video/__init__.py +1 -0
- supervisely/convert/video/multi_view/__init__.py +0 -0
- supervisely/convert/video/multi_view/multi_view.py +543 -0
- supervisely/convert/video/sly/sly_video_converter.py +359 -3
- supervisely/convert/video/video_converter.py +24 -4
- supervisely/convert/volume/dicom/dicom_converter.py +13 -5
- supervisely/convert/volume/dicom/dicom_helper.py +30 -18
- supervisely/geometry/constants.py +1 -0
- supervisely/geometry/geometry.py +4 -0
- supervisely/geometry/helpers.py +5 -1
- supervisely/geometry/oriented_bbox.py +676 -0
- supervisely/geometry/polyline_3d.py +110 -0
- supervisely/geometry/rectangle.py +2 -1
- supervisely/io/env.py +76 -1
- supervisely/io/fs.py +21 -0
- supervisely/nn/benchmark/base_evaluator.py +104 -11
- supervisely/nn/benchmark/instance_segmentation/evaluator.py +1 -8
- supervisely/nn/benchmark/object_detection/evaluator.py +20 -4
- supervisely/nn/benchmark/object_detection/vis_metrics/pr_curve.py +10 -5
- supervisely/nn/benchmark/semantic_segmentation/evaluator.py +34 -16
- supervisely/nn/benchmark/semantic_segmentation/vis_metrics/confusion_matrix.py +1 -1
- supervisely/nn/benchmark/semantic_segmentation/vis_metrics/frequently_confused.py +1 -1
- supervisely/nn/benchmark/semantic_segmentation/vis_metrics/overview.py +1 -1
- supervisely/nn/benchmark/visualization/evaluation_result.py +66 -4
- supervisely/nn/inference/cache.py +43 -18
- supervisely/nn/inference/gui/serving_gui_template.py +5 -2
- supervisely/nn/inference/inference.py +916 -222
- supervisely/nn/inference/inference_request.py +55 -10
- supervisely/nn/inference/predict_app/gui/classes_selector.py +83 -12
- supervisely/nn/inference/predict_app/gui/gui.py +676 -488
- supervisely/nn/inference/predict_app/gui/input_selector.py +205 -26
- supervisely/nn/inference/predict_app/gui/model_selector.py +2 -4
- supervisely/nn/inference/predict_app/gui/output_selector.py +46 -6
- supervisely/nn/inference/predict_app/gui/settings_selector.py +756 -59
- supervisely/nn/inference/predict_app/gui/tags_selector.py +1 -1
- supervisely/nn/inference/predict_app/gui/utils.py +236 -119
- supervisely/nn/inference/predict_app/predict_app.py +2 -2
- supervisely/nn/inference/session.py +43 -35
- supervisely/nn/inference/tracking/bbox_tracking.py +118 -35
- supervisely/nn/inference/tracking/point_tracking.py +5 -1
- supervisely/nn/inference/tracking/tracker_interface.py +10 -1
- supervisely/nn/inference/uploader.py +139 -12
- supervisely/nn/live_training/__init__.py +7 -0
- supervisely/nn/live_training/api_server.py +111 -0
- supervisely/nn/live_training/artifacts_utils.py +243 -0
- supervisely/nn/live_training/checkpoint_utils.py +229 -0
- supervisely/nn/live_training/dynamic_sampler.py +44 -0
- supervisely/nn/live_training/helpers.py +14 -0
- supervisely/nn/live_training/incremental_dataset.py +146 -0
- supervisely/nn/live_training/live_training.py +497 -0
- supervisely/nn/live_training/loss_plateau_detector.py +111 -0
- supervisely/nn/live_training/request_queue.py +52 -0
- supervisely/nn/model/model_api.py +9 -0
- supervisely/nn/model/prediction.py +2 -1
- supervisely/nn/model/prediction_session.py +26 -14
- supervisely/nn/prediction_dto.py +19 -1
- supervisely/nn/tracker/base_tracker.py +11 -1
- supervisely/nn/tracker/botsort/botsort_config.yaml +0 -1
- supervisely/nn/tracker/botsort/tracker/mc_bot_sort.py +7 -4
- supervisely/nn/tracker/botsort_tracker.py +94 -65
- supervisely/nn/tracker/utils.py +4 -5
- supervisely/nn/tracker/visualize.py +93 -93
- supervisely/nn/training/gui/classes_selector.py +16 -1
- supervisely/nn/training/gui/train_val_splits_selector.py +52 -31
- supervisely/nn/training/train_app.py +46 -31
- supervisely/project/data_version.py +115 -51
- supervisely/project/download.py +1 -1
- supervisely/project/pointcloud_episode_project.py +37 -8
- supervisely/project/pointcloud_project.py +30 -2
- supervisely/project/project.py +14 -2
- supervisely/project/project_meta.py +27 -1
- supervisely/project/project_settings.py +32 -18
- supervisely/project/versioning/__init__.py +1 -0
- supervisely/project/versioning/common.py +20 -0
- supervisely/project/versioning/schema_fields.py +35 -0
- supervisely/project/versioning/video_schema.py +221 -0
- supervisely/project/versioning/volume_schema.py +87 -0
- supervisely/project/video_project.py +717 -15
- supervisely/project/volume_project.py +623 -5
- supervisely/template/experiment/experiment.html.jinja +4 -4
- supervisely/template/experiment/experiment_generator.py +14 -21
- supervisely/template/live_training/__init__.py +0 -0
- supervisely/template/live_training/header.html.jinja +96 -0
- supervisely/template/live_training/live_training.html.jinja +51 -0
- supervisely/template/live_training/live_training_generator.py +464 -0
- supervisely/template/live_training/sly-style.css +402 -0
- supervisely/template/live_training/template.html.jinja +18 -0
- supervisely/versions.json +28 -26
- supervisely/video/sampling.py +39 -20
- supervisely/video/video.py +41 -12
- supervisely/video_annotation/video_figure.py +38 -4
- supervisely/video_annotation/video_object.py +29 -4
- supervisely/volume/stl_converter.py +2 -0
- supervisely/worker_api/agent_rpc.py +24 -1
- supervisely/worker_api/rpc_servicer.py +31 -7
- {supervisely-6.73.438.dist-info → supervisely-6.73.513.dist-info}/METADATA +58 -40
- {supervisely-6.73.438.dist-info → supervisely-6.73.513.dist-info}/RECORD +203 -155
- {supervisely-6.73.438.dist-info → supervisely-6.73.513.dist-info}/WHEEL +1 -1
- supervisely_lib/__init__.py +6 -1
- {supervisely-6.73.438.dist-info → supervisely-6.73.513.dist-info}/entry_points.txt +0 -0
- {supervisely-6.73.438.dist-info → supervisely-6.73.513.dist-info/licenses}/LICENSE +0 -0
- {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
|
-
|
|
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
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
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
|
"""
|
supervisely/app/__init__.py
CHANGED
|
@@ -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
|
supervisely/app/content.py
CHANGED
|
@@ -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
|
-
|
|
115
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
|
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(
|
|
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(
|
|
@@ -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
|
-
|
|
630
|
-
|
|
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
|
-
|
|
636
|
-
|
|
637
|
-
|
|
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
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
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
|
-
|
|
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
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
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)
|