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

supervisely/__init__.py CHANGED
@@ -309,4 +309,4 @@ except Exception as e:
309
309
  # If new changes in Supervisely Python SDK require upgrade of the Supervisely instance
310
310
  # set a new value for the environment variable MINIMUM_INSTANCE_VERSION_FOR_SDK, otherwise
311
311
  # users can face compatibility issues, if the instance version is lower than the SDK version.
312
- os.environ["MINIMUM_INSTANCE_VERSION_FOR_SDK"] = "6.11.16"
312
+ os.environ["MINIMUM_INSTANCE_VERSION_FOR_SDK"] = "6.12.5"
supervisely/_utils.py CHANGED
@@ -14,7 +14,7 @@ import urllib
14
14
  from datetime import datetime
15
15
  from functools import wraps
16
16
  from tempfile import gettempdir
17
- from typing import List, Literal, Optional
17
+ from typing import Any, Dict, List, Literal, Optional, Tuple
18
18
 
19
19
  import numpy as np
20
20
  from requests.utils import DEFAULT_CA_BUNDLE_PATH
@@ -316,6 +316,15 @@ def get_readable_datetime(value: str) -> str:
316
316
  return dt.strftime("%Y-%m-%d %H:%M:%S")
317
317
 
318
318
 
319
+ def get_unix_timestamp() -> int:
320
+ """Return the current Unix timestamp.
321
+
322
+ :return: Current Unix timestamp.
323
+ :rtype: int
324
+ """
325
+ return int(time.time())
326
+
327
+
319
328
  def get_certificates_list(path: str = DEFAULT_CA_BUNDLE_PATH) -> List[str]:
320
329
  with open(path, "r", encoding="ascii") as f:
321
330
  content = f.read().strip()
@@ -385,6 +394,51 @@ def add_callback(func, callback):
385
394
  return wrapper
386
395
 
387
396
 
397
+ def compare_dicts(
398
+ template: Dict[Any, Any], data: Dict[Any, Any], strict: bool = True
399
+ ) -> Tuple[List[str], List[str]]:
400
+ """Compare two dictionaries recursively (by keys only) and return lists of missing and extra fields.
401
+ If strict is True, the keys of the template and data dictionaries must match exactly.
402
+ Otherwise, the data dictionary may contain additional keys that are not in the template dictionary.
403
+
404
+ :param template: The template dictionary.
405
+ :type template: Dict[Any, Any]
406
+ :param data: The data dictionary.
407
+ :type data: Dict[Any, Any]
408
+ :param strict: If True, the keys of the template and data dictionaries must match exactly.
409
+ :type strict: bool, optional
410
+ :return: A tuple containing a list of missing fields and a list of extra fields.
411
+ :rtype: Tuple[List[str], List[str]]
412
+ """
413
+ missing_fields = []
414
+ extra_fields = []
415
+
416
+ if not isinstance(template, dict) or not isinstance(data, dict):
417
+ return missing_fields, extra_fields
418
+
419
+ if strict:
420
+ template_keys = set(template.keys())
421
+ data_keys = set(data.keys())
422
+
423
+ missing_fields = list(template_keys - data_keys)
424
+ extra_fields = list(data_keys - template_keys)
425
+
426
+ for key in template_keys & data_keys:
427
+ sub_missing, sub_extra = compare_dicts(template[key], data[key], strict)
428
+ missing_fields.extend([f"{key}.{m}" for m in sub_missing])
429
+ extra_fields.extend([f"{key}.{e}" for e in sub_extra])
430
+ else:
431
+ for key in template:
432
+ if key not in data:
433
+ missing_fields.append(key)
434
+ else:
435
+ sub_missing, sub_extra = compare_dicts(template[key], data[key], strict)
436
+ missing_fields.extend([f"{key}.{m}" for m in sub_missing])
437
+ extra_fields.extend([f"{key}.{e}" for e in sub_extra])
438
+
439
+ return missing_fields, extra_fields
440
+
441
+
388
442
  def get_or_create_event_loop() -> asyncio.AbstractEventLoop:
389
443
  """
390
444
  Get the current event loop or create a new one if it doesn't exist.
@@ -1388,7 +1388,7 @@ class AnnotationApi(ModuleApi):
1388
1388
  ann_info = loop.run_until_complete(api.annotation.download_async(image_id))
1389
1389
  """
1390
1390
  if semaphore is None:
1391
- semaphore = self._api._get_default_semaphore()
1391
+ semaphore = self._api.get_default_semaphore()
1392
1392
  async with semaphore:
1393
1393
  response = await self._api.post_async(
1394
1394
  "annotations.info",
@@ -1501,7 +1501,7 @@ class AnnotationApi(ModuleApi):
1501
1501
  context["project_meta"] = project_meta
1502
1502
 
1503
1503
  if semaphore is None:
1504
- semaphore = self._api._get_default_semaphore()
1504
+ semaphore = self._api.get_default_semaphore()
1505
1505
  tasks = []
1506
1506
  for image in image_ids:
1507
1507
  task = self.download_async(
supervisely/api/api.py CHANGED
@@ -377,6 +377,7 @@ class Api:
377
377
 
378
378
  self.async_httpx_client: httpx.AsyncClient = None
379
379
  self.httpx_client: httpx.Client = None
380
+ self._semaphore = None
380
381
 
381
382
  @classmethod
382
383
  def normalize_server_address(cls, server_address: str) -> str:
@@ -999,7 +1000,10 @@ class Api:
999
1000
  def post_httpx(
1000
1001
  self,
1001
1002
  method: str,
1002
- data: Union[bytes, Dict],
1003
+ json: Dict = None,
1004
+ content: Union[str, bytes, Iterable[bytes], AsyncIterable[bytes]] = None,
1005
+ files: Union[Mapping] = None,
1006
+ params: Union[str, bytes] = None,
1003
1007
  headers: Optional[Dict[str, str]] = None,
1004
1008
  retries: Optional[int] = None,
1005
1009
  raise_error: Optional[bool] = False,
@@ -1009,8 +1013,14 @@ class Api:
1009
1013
 
1010
1014
  :param method: Method name.
1011
1015
  :type method: str
1012
- :param data: Bytes with data content or dictionary with params.
1013
- :type data: bytes or dict
1016
+ :param json: Dictionary to send in the body of request.
1017
+ :type json: dict, optional
1018
+ :param content: Bytes with data content or dictionary with params.
1019
+ :type content: bytes or dict, optional
1020
+ :param files: Files to send in the body of request.
1021
+ :type files: dict, optional
1022
+ :param params: URL query parameters.
1023
+ :type params: str, bytes, optional
1014
1024
  :param headers: Custom headers to include in the request.
1015
1025
  :type headers: dict, optional
1016
1026
  :param retries: The number of attempts to connect to the server.
@@ -1021,6 +1031,7 @@ class Api:
1021
1031
  :rtype: :class:`httpx.Response`
1022
1032
  """
1023
1033
  self._set_client()
1034
+
1024
1035
  if retries is None:
1025
1036
  retries = self.retry_count
1026
1037
 
@@ -1032,18 +1043,17 @@ class Api:
1032
1043
  else:
1033
1044
  headers = {**self.headers, **headers}
1034
1045
 
1035
- if isinstance(data, bytes):
1036
- request_params = {"content": data}
1037
- elif isinstance(data, Dict):
1038
- json_body = {**data, **self.additional_fields}
1039
- request_params = {"json": json_body}
1040
- else:
1041
- request_params = {"params": data}
1042
-
1043
1046
  for retry_idx in range(retries):
1044
1047
  response = None
1045
1048
  try:
1046
- response = self.httpx_client.post(url, headers=headers, **request_params)
1049
+ response = self.httpx_client.post(
1050
+ url,
1051
+ content=content,
1052
+ files=files,
1053
+ json=json,
1054
+ params=params,
1055
+ headers=headers,
1056
+ )
1047
1057
  if response.status_code != httpx.codes.OK:
1048
1058
  self._check_version()
1049
1059
  Api._raise_for_status_httpx(response)
@@ -1065,9 +1075,8 @@ class Api:
1065
1075
  )
1066
1076
  except Exception as exc:
1067
1077
  process_unhandled_request(self.logger, exc)
1068
- raise httpx.HTTPStatusError(
1069
- "Retry limit exceeded ({!r})".format(url),
1070
- response=response,
1078
+ raise httpx.RequestError(
1079
+ f"Retry limit exceeded ({url})",
1071
1080
  request=getattr(response, "request", None),
1072
1081
  )
1073
1082
 
@@ -1093,6 +1102,7 @@ class Api:
1093
1102
  :rtype: :class:`Response<Response>`
1094
1103
  """
1095
1104
  self._set_client()
1105
+
1096
1106
  if retries is None:
1097
1107
  retries = self.retry_count
1098
1108
 
@@ -1171,8 +1181,8 @@ class Api:
1171
1181
  :return: Generator object.
1172
1182
  :rtype: :class:`Generator`
1173
1183
  """
1174
-
1175
1184
  self._set_client()
1185
+
1176
1186
  if retries is None:
1177
1187
  retries = self.retry_count
1178
1188
 
@@ -1408,14 +1418,13 @@ class Api:
1408
1418
  url = self.api_server_address + "/v3/" + method
1409
1419
  if not use_public_api:
1410
1420
  url = os.path.join(self.server_address, method)
1421
+ logger.trace(f"{method_type} {url}")
1411
1422
 
1412
1423
  if headers is None:
1413
1424
  headers = self.headers.copy()
1414
1425
  else:
1415
1426
  headers = {**self.headers, **headers}
1416
1427
 
1417
- logger.trace(f"{method_type} {url}")
1418
-
1419
1428
  if isinstance(data, (bytes, Generator)):
1420
1429
  content = data
1421
1430
  json_body = None
@@ -1506,42 +1515,80 @@ class Api:
1506
1515
 
1507
1516
  def _set_async_client(self):
1508
1517
  """
1509
- Set async httpx client if it is not set yet.
1510
- Switch on HTTP/2 if the server address uses HTTPS.
1518
+ Set async httpx client with HTTP/2 if it is not set yet.
1511
1519
  """
1512
1520
  if self.async_httpx_client is None:
1513
- self._check_https_redirect()
1514
- if self.server_address.startswith("https://"):
1515
- self.async_httpx_client = httpx.AsyncClient(http2=True)
1516
- else:
1517
- self.async_httpx_client = httpx.AsyncClient()
1521
+ self.async_httpx_client = httpx.AsyncClient(http2=True)
1518
1522
 
1519
1523
  def _set_client(self):
1520
1524
  """
1521
- Set httpx client if it is not set yet.
1522
- Switch on HTTP/2 if the server address uses HTTPS.
1525
+ Set sync httpx client with HTTP/2 if it is not set yet.
1523
1526
  """
1524
1527
  if self.httpx_client is None:
1525
- self._check_https_redirect()
1526
- if self.server_address.startswith("https://"):
1527
- self.httpx_client = httpx.Client(http2=True)
1528
- else:
1529
- self.httpx_client = httpx.Client()
1528
+ self.httpx_client = httpx.Client(http2=True)
1529
+
1530
+ def get_default_semaphore(self) -> asyncio.Semaphore:
1531
+ """
1532
+ Get default global API semaphore for async requests.
1533
+ If the semaphore is not set, it will be initialized.
1534
+
1535
+ During initialization, the semaphore size will be set from the environment variable SUPERVISELY_ASYNC_SEMAPHORE.
1536
+ If the environment variable is not set, the default value will be set based on the server address.
1537
+ Depending on the server address, the semaphore size will be set to 10 for HTTPS and 5 for HTTP.
1538
+
1539
+ :return: Semaphore object.
1540
+ :rtype: :class:`asyncio.Semaphore`
1541
+ """
1542
+ if self._semaphore is None:
1543
+ self._initialize_semaphore()
1544
+ return self._semaphore
1530
1545
 
1531
- def _get_default_semaphore(self):
1546
+ def _initialize_semaphore(self):
1532
1547
  """
1533
- Get default semaphore for async requests.
1534
- Check if the environment variable SUPERVISELY_ASYNC_SEMAPHORE is set.
1535
- If it is set, create a semaphore with the given value.
1536
- Otherwise, create a semaphore with a default value.
1537
- If server supports HTTPS, create a semaphore with a higher value.
1548
+ Initialize the semaphore for async requests.
1549
+
1550
+ If the environment variable SUPERVISELY_ASYNC_SEMAPHORE is set, create a semaphore with the given value.
1551
+
1552
+ Otherwise, create a semaphore with a default value:
1553
+
1554
+ - If server supports HTTPS, create a semaphore with value 10.
1555
+ - If server supports HTTP, create a semaphore with value 5.
1538
1556
  """
1539
- env_semaphore = os.getenv("SUPERVISELY_ASYNC_SEMAPHORE")
1540
- if env_semaphore is not None:
1541
- return asyncio.Semaphore(int(env_semaphore))
1557
+ semaphore_size = sly_env.semaphore_size()
1558
+ if semaphore_size is not None:
1559
+ self._semaphore = asyncio.Semaphore(semaphore_size)
1560
+ logger.debug(
1561
+ f"Setting global API semaphore size to {semaphore_size} from environment variable"
1562
+ )
1542
1563
  else:
1543
1564
  self._check_https_redirect()
1544
1565
  if self.server_address.startswith("https://"):
1545
- return asyncio.Semaphore(25)
1566
+ self._semaphore = asyncio.Semaphore(10)
1567
+ logger.debug("Setting global API semaphore size to 10 for HTTPS")
1546
1568
  else:
1547
- return asyncio.Semaphore(5)
1569
+ self._semaphore = asyncio.Semaphore(5)
1570
+ logger.debug("Setting global API semaphore size to 5 for HTTP")
1571
+
1572
+ def set_semaphore_size(self, size: int = None):
1573
+ """
1574
+ Set the global API semaphore with the given size. Will replace the existing semaphore.
1575
+ If the size is not set, will set from the environment variable SUPERVISELY_ASYNC_SEMAPHORE.
1576
+ If the environment variable is not set, will set the default value.
1577
+
1578
+ :param size: Size of the semaphore.
1579
+ :type size: int, optional
1580
+ """
1581
+ if size is not None:
1582
+ self._semaphore = asyncio.Semaphore(size)
1583
+ else:
1584
+ self._initialize_semaphore()
1585
+
1586
+ @property
1587
+ def semaphore(self) -> asyncio.Semaphore:
1588
+ """
1589
+ Get the global API semaphore for async requests.
1590
+
1591
+ :return: Semaphore object.
1592
+ :rtype: :class:`asyncio.Semaphore`
1593
+ """
1594
+ return self._semaphore
@@ -551,7 +551,7 @@ class AppApi(TaskApi):
551
551
 
552
552
  def is_enabled(self) -> bool:
553
553
  """Check if the workflow functionality is enabled."""
554
- logger.info(f"Workflow check: is {'enabled' if self._enabled else 'disabled'}.")
554
+ logger.debug(f"Workflow check: is {'enabled' if self._enabled else 'disabled'}.")
555
555
  return self._enabled
556
556
 
557
557
  # pylint: disable=no-self-argument
@@ -579,8 +579,8 @@ class AppApi(TaskApi):
579
579
  ):
580
580
  self.__last_warning_time = time.monotonic()
581
581
  logger.warning(
582
- "Workflow is disabled in development mode. "
583
- "To enable it, use `api.app.workflow.enable()` right after Api initialization."
582
+ "Workflow is disabled. "
583
+ "To enable it, use `api.app.workflow.enable()`."
584
584
  )
585
585
  return
586
586
  if not check_workflow_compatibility(self._api, version_to_check):
@@ -4,7 +4,17 @@
4
4
  # docs
5
5
  from __future__ import annotations
6
6
 
7
- from typing import Dict, Generator, List, Literal, NamedTuple, Optional, Tuple, Union
7
+ from typing import (
8
+ Any,
9
+ Dict,
10
+ Generator,
11
+ List,
12
+ Literal,
13
+ NamedTuple,
14
+ Optional,
15
+ Tuple,
16
+ Union,
17
+ )
8
18
 
9
19
  from supervisely._utils import abs_url, compress_image_url, is_development
10
20
  from supervisely.api.module_api import (
@@ -61,6 +71,7 @@ class DatasetInfo(NamedTuple):
61
71
  team_id: int
62
72
  workspace_id: int
63
73
  parent_id: Union[int, None]
74
+ custom_data: dict
64
75
 
65
76
  @property
66
77
  def image_preview_url(self):
@@ -135,6 +146,7 @@ class DatasetApi(UpdateableModule, RemoveableModuleApi):
135
146
  ApiField.TEAM_ID,
136
147
  ApiField.WORKSPACE_ID,
137
148
  ApiField.PARENT_ID,
149
+ ApiField.CUSTOM_DATA,
138
150
  ]
139
151
 
140
152
  @staticmethod
@@ -362,6 +374,83 @@ class DatasetApi(UpdateableModule, RemoveableModuleApi):
362
374
  )
363
375
  return dataset_info
364
376
 
377
+ def update(
378
+ self,
379
+ id: int,
380
+ name: Optional[str] = None,
381
+ description: Optional[str] = None,
382
+ custom_data: Optional[Dict[Any, Any]] = None,
383
+ ) -> DatasetInfo:
384
+ """Update Dataset information by given ID.
385
+
386
+ :param id: Dataset ID in Supervisely.
387
+ :type id: int
388
+ :param name: New Dataset name.
389
+ :type name: str, optional
390
+ :param description: New Dataset description.
391
+ :type description: str, optional
392
+ :param custom_data: New custom data.
393
+ :type custom_data: Dict[Any, Any], optional
394
+ :return: Information about Dataset. See :class:`info_sequence<info_sequence>`
395
+ :rtype: :class:`DatasetInfo`
396
+
397
+ :Usage example:
398
+
399
+ .. code-block:: python
400
+
401
+ import supervisely as sly
402
+
403
+ dataset_id = 384126
404
+
405
+ os.environ['SERVER_ADDRESS'] = 'https://app.supervisely.com'
406
+ os.environ['API_TOKEN'] = 'Your Supervisely API Token'
407
+ api = sly.Api.from_env()
408
+
409
+ new_ds = api.dataset.update(dataset_id, name='new_ds', description='new description')
410
+ """
411
+ fields = [name, description, custom_data] # Extend later if needed.
412
+ if all(f is None for f in fields):
413
+ raise ValueError(f"At least one of the fields must be specified: {fields}")
414
+
415
+ payload = {
416
+ ApiField.ID: id,
417
+ ApiField.NAME: name,
418
+ ApiField.DESCRIPTION: description,
419
+ ApiField.CUSTOM_DATA: custom_data,
420
+ }
421
+
422
+ payload = {k: v for k, v in payload.items() if v is not None}
423
+
424
+ response = self._api.post(self._get_update_method(), payload)
425
+ return self._convert_json_info(response.json())
426
+
427
+ def update_custom_data(self, id: int, custom_data: Dict[Any, Any]) -> DatasetInfo:
428
+ """Update custom data for Dataset by given ID.
429
+ Custom data is a dictionary that can store any additional information about the Dataset.
430
+
431
+ :param id: Dataset ID in Supervisely.
432
+ :type id: int
433
+ :param custom_data: New custom data.
434
+ :type custom_data: Dict[Any, Any]
435
+ :return: Information about Dataset. See :class:`info_sequence<info_sequence>`
436
+ :rtype: :class:`DatasetInfo`
437
+
438
+ :Usage example:
439
+
440
+ .. code-block:: python
441
+
442
+ import supervisely as sly
443
+
444
+ dataset_id = 384126
445
+
446
+ os.environ['SERVER_ADDRESS'] = 'https://app.supervisely.com'
447
+ os.environ['API_TOKEN'] = 'Your Supervisely API Token'
448
+ api = sly.Api.from_env()
449
+
450
+ new_ds = api.dataset.update_custom_data(dataset_id, custom_data={'key': 'value'})
451
+ """
452
+ return self.update(id, custom_data=custom_data)
453
+
365
454
  def _get_update_method(self):
366
455
  """ """
367
456
  return "datasets.editInfo"
@@ -1646,7 +1646,7 @@ class FileApi(ModuleApiBase):
1646
1646
  loop.run_until_complete(api.file.download_async(8, path_to_file, local_save_path))
1647
1647
  """
1648
1648
  if semaphore is None:
1649
- semaphore = self._api._get_default_semaphore()
1649
+ semaphore = self._api.get_default_semaphore()
1650
1650
  async with semaphore:
1651
1651
  if self.is_on_agent(remote_path):
1652
1652
  # for optimized download from agent
@@ -1774,7 +1774,7 @@ class FileApi(ModuleApiBase):
1774
1774
  )
1775
1775
 
1776
1776
  if semaphore is None:
1777
- semaphore = self._api._get_default_semaphore()
1777
+ semaphore = self._api.get_default_semaphore()
1778
1778
 
1779
1779
  tasks = []
1780
1780
  for remote_path, local_path, cache in zip(
@@ -1836,7 +1836,7 @@ class FileApi(ModuleApiBase):
1836
1836
  """
1837
1837
 
1838
1838
  if semaphore is None:
1839
- semaphore = self._api._get_default_semaphore()
1839
+ semaphore = self._api.get_default_semaphore()
1840
1840
 
1841
1841
  if not remote_path.endswith("/"):
1842
1842
  remote_path += "/"
@@ -1925,7 +1925,7 @@ class FileApi(ModuleApiBase):
1925
1925
  """
1926
1926
 
1927
1927
  if semaphore is None:
1928
- semaphore = self._api._get_default_semaphore()
1928
+ semaphore = self._api.get_default_semaphore()
1929
1929
 
1930
1930
  remote_file_path = env.file(raise_not_found=False)
1931
1931
  remote_folder_path = env.folder(raise_not_found=False)
@@ -2061,7 +2061,7 @@ class FileApi(ModuleApiBase):
2061
2061
  # "sha256": sha256, #TODO add with resumaple api
2062
2062
  }
2063
2063
  if semaphore is None:
2064
- semaphore = self._api._get_default_semaphore()
2064
+ semaphore = self._api.get_default_semaphore()
2065
2065
  async with semaphore:
2066
2066
  async with aiofiles.open(src, "rb") as fd:
2067
2067
  item = await fd.read()
@@ -2129,7 +2129,7 @@ class FileApi(ModuleApiBase):
2129
2129
  )
2130
2130
  """
2131
2131
  if semaphore is None:
2132
- semaphore = self._api._get_default_semaphore()
2132
+ semaphore = self._api.get_default_semaphore()
2133
2133
  tasks = []
2134
2134
  for s, d in zip(src_paths, dst_paths):
2135
2135
  task = self.upload_async(