Skip to content

Commit 6cec665

Browse files
authored
Merge pull request #2009 from kili-technology/feature/lab-4123-faster-exports-with-the-sdk
feat(LAB-4123): faster exports with the SDK
2 parents f2b2b13 + e0e6ae2 commit 6cec665

12 files changed

Lines changed: 639 additions & 69 deletions

File tree

Lines changed: 117 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,121 @@
11
"""Formatters for assets retrieved from Kili API."""
22

3+
import asyncio
34
import json
45

6+
import requests
7+
58
from kili.adapters.http_client import HttpClient
6-
from kili.core.helpers import get_response_json, is_url, log_raise_for_status
9+
from kili.adapters.kili_api_gateway.helpers.http_json import load_json_from_link
10+
from kili.core.helpers import is_url
711
from kili.domain.types import ListOrTuple
812

13+
# Batch size for parallel JSON response downloads (same as export service)
14+
JSON_RESPONSE_BATCH_SIZE = 10
15+
16+
17+
async def _download_json_response(url: str, http_client: HttpClient) -> dict:
18+
"""Download and parse JSON response from a URL using asyncio.
19+
20+
Args:
21+
url: URL to download the JSON response from
22+
http_client: HttpClient instance with SSL verification already configured
923
10-
def load_json_from_link(link: str, http_client: HttpClient) -> dict:
11-
"""Load json from link."""
12-
if link == "" or not is_url(link):
24+
Returns:
25+
Parsed JSON response as a dictionary
26+
"""
27+
try:
28+
# Run synchronous requests call in a thread
29+
response = await asyncio.to_thread(http_client.get, url, timeout=30)
30+
response.raise_for_status()
31+
return response.json()
32+
except (requests.RequestException, json.JSONDecodeError):
33+
# Return empty dict on error to ensure consistent response format
1334
return {}
1435

15-
response = http_client.get(link, timeout=30)
16-
log_raise_for_status(response)
17-
return get_response_json(response)
36+
37+
async def _download_json_responses_async(
38+
url_to_label_mapping: list[tuple[str, dict]], http_client: HttpClient
39+
) -> None:
40+
"""Download JSON responses in parallel using asyncio.
41+
42+
Args:
43+
url_to_label_mapping: List of tuples (url, label_dict) to download
44+
http_client: HttpClient instance with SSL verification already configured
45+
"""
46+
# Process in batches to limit concurrent connections
47+
for i in range(0, len(url_to_label_mapping), JSON_RESPONSE_BATCH_SIZE):
48+
batch = url_to_label_mapping[i : i + JSON_RESPONSE_BATCH_SIZE]
49+
50+
# Download all URLs in the batch in parallel using asyncio.gather
51+
download_tasks = [_download_json_response(url, http_client) for url, _ in batch]
52+
json_responses = await asyncio.gather(*download_tasks)
53+
54+
# Assign the downloaded responses back to their labels and remove the URL
55+
for (_, label), json_response in zip(batch, json_responses, strict=False):
56+
label["jsonResponse"] = json_response
57+
if "jsonResponseUrl" in label:
58+
del label["jsonResponseUrl"]
59+
60+
61+
def download_json_responses_parallel(
62+
url_to_label_mapping: list[tuple[str, dict]], http_client: HttpClient
63+
) -> None:
64+
"""Download JSON responses in parallel and assign to labels.
65+
66+
Args:
67+
url_to_label_mapping: List of tuples (url, label_dict) to download
68+
http_client: HttpClient instance with SSL verification already configured
69+
"""
70+
if not url_to_label_mapping:
71+
return
72+
73+
# Check if we're already in an event loop (e.g., Jupyter notebook, async web framework)
74+
try:
75+
asyncio.get_running_loop()
76+
except RuntimeError:
77+
# No running loop, safe to use asyncio.run() for parallel downloads
78+
asyncio.run(_download_json_responses_async(url_to_label_mapping, http_client))
79+
else:
80+
# Already in a loop - fall back to sequential downloads to avoid RuntimeError
81+
# This happens in Jupyter notebooks, FastAPI, and other async contexts
82+
for url, label in url_to_label_mapping:
83+
try:
84+
response = http_client.get(url, timeout=30)
85+
response.raise_for_status()
86+
label["jsonResponse"] = response.json()
87+
except (requests.RequestException, json.JSONDecodeError):
88+
label["jsonResponse"] = {}
89+
90+
if "jsonResponseUrl" in label:
91+
del label["jsonResponseUrl"]
92+
93+
94+
def _parse_label_json_response(label: dict) -> None:
95+
"""Parse jsonResponse string to dict for a single label.
96+
97+
Args:
98+
label: Label dict to update in place
99+
"""
100+
json_response_value = label.get("jsonResponse", "{}")
101+
try:
102+
label["jsonResponse"] = json.loads(json_response_value)
103+
except json.JSONDecodeError:
104+
label["jsonResponse"] = {}
105+
106+
107+
def _process_label_json_response(label: dict, url_to_label_mapping: list[tuple[str, dict]]) -> None:
108+
"""Process a single label's jsonResponse, either scheduling URL download or parsing.
109+
110+
Args:
111+
label: Label dict to process
112+
url_to_label_mapping: List to append URL mapping if download needed
113+
"""
114+
json_response_url = label.get("jsonResponseUrl")
115+
if json_response_url and is_url(json_response_url):
116+
url_to_label_mapping.append((json_response_url, label))
117+
else:
118+
_parse_label_json_response(label)
18119

19120

20121
def load_asset_json_fields(asset: dict, fields: ListOrTuple[str], http_client: HttpClient) -> dict:
@@ -28,18 +129,17 @@ def load_asset_json_fields(asset: dict, fields: ListOrTuple[str], http_client: H
28129
if "ocrMetadata" in fields and asset.get("ocrMetadata") is not None:
29130
asset["ocrMetadata"] = load_json_from_link(asset.get("ocrMetadata", ""), http_client)
30131

132+
# Collect all URLs to download in parallel (similar to export service)
133+
url_to_label_mapping = []
134+
31135
if "labels.jsonResponse" in fields:
32-
asset_labels = asset.get("labels", [])
33-
for label in asset_labels:
34-
try:
35-
label["jsonResponse"] = json.loads(label["jsonResponse"])
36-
except json.JSONDecodeError:
37-
label["jsonResponse"] = {}
136+
for label in asset.get("labels", []):
137+
_process_label_json_response(label, url_to_label_mapping)
38138

39139
if "latestLabel.jsonResponse" in fields and asset.get("latestLabel") is not None:
40-
try:
41-
asset["latestLabel"]["jsonResponse"] = json.loads(asset["latestLabel"]["jsonResponse"])
42-
except json.JSONDecodeError:
43-
asset["latestLabel"]["jsonResponse"] = {}
140+
_process_label_json_response(asset["latestLabel"], url_to_label_mapping)
141+
142+
if url_to_label_mapping:
143+
download_json_responses_parallel(url_to_label_mapping, http_client)
44144

45145
return asset

src/kili/adapters/kili_api_gateway/asset/operations_mixin.py

Lines changed: 51 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,9 @@ def list_assets(
4646
options: QueryOptions,
4747
) -> Generator[dict, None, None]:
4848
"""List assets with given options."""
49+
has_labels_url = "labels.jsonResponseUrl" in fields
50+
has_latest_label_url = "latestLabel.jsonResponseUrl" in fields
51+
4952
if "labels.jsonResponse" in fields or "latestLabel.jsonResponse" in fields:
5053
# Check if we can get the jsonResponse of if we need to rebuild it.
5154
project_info = get_project(
@@ -58,7 +61,10 @@ def list_assets(
5861
"LLM_STATIC",
5962
"GEOSPATIAL",
6063
}:
61-
yield from self.list_assets_split(filters, fields, options, project_info)
64+
fetch_annotations = not (has_labels_url or has_latest_label_url)
65+
yield from self.list_assets_split(
66+
filters, fields, options, project_info, fetch_annotations
67+
)
6268
return
6369

6470
fragment = fragment_builder(fields)
@@ -79,7 +85,12 @@ def list_assets(
7985
yield from assets_gen
8086

8187
def list_assets_split(
82-
self, filters: AssetFilters, fields: ListOrTuple[str], options: QueryOptions, project_info
88+
self,
89+
filters: AssetFilters,
90+
fields: ListOrTuple[str],
91+
options: QueryOptions,
92+
project_info,
93+
fetch_annotations: bool,
8394
) -> Generator[dict, None, None]:
8495
"""List assets with given options."""
8596
nb_annotations = self.count_assets_annotations(filters)
@@ -91,22 +102,23 @@ def list_assets_split(
91102

92103
options = QueryOptions(options.disable_tqdm, options.first, options.skip, batch_size)
93104

94-
inner_annotation_fragment = get_annotation_fragment()
95-
annotation_fragment = f"""
96-
annotations {{
97-
{inner_annotation_fragment}
98-
}}
99-
"""
100-
# Ensure 'content', 'resolution', and 'jsonContent' are present in fields
101-
required_fields = {"content", "jsonContent", "resolution.width", "resolution.height"}
102-
fields = list(fields)
103-
for field in required_fields:
104-
if field not in fields:
105-
fields.append(field)
106-
107-
fragment = fragment_builder(
108-
fields, {"labels": annotation_fragment, "latestLabel": annotation_fragment}
109-
)
105+
static_fragments = {}
106+
if fetch_annotations:
107+
inner_annotation_fragment = get_annotation_fragment()
108+
annotation_fragment = f"""
109+
annotations {{
110+
{inner_annotation_fragment}
111+
}}
112+
"""
113+
static_fragments = {"labels": annotation_fragment, "latestLabel": annotation_fragment}
114+
115+
required_fields = {"content", "jsonContent", "resolution.width", "resolution.height"}
116+
fields = list(fields)
117+
for field in required_fields:
118+
if field not in fields:
119+
fields.append(field)
120+
121+
fragment = fragment_builder(fields, static_fragments if static_fragments else None)
110122
query = get_assets_query(fragment)
111123
where = asset_where_mapper(filters)
112124
assets_gen = PaginatedGraphQLQuery(self.graphql_client).execute_query_from_paginated_call(
@@ -115,25 +127,29 @@ def list_assets_split(
115127
assets_gen = (
116128
load_asset_json_fields(asset, fields, self.http_client) for asset in assets_gen
117129
)
118-
converter = AnnotationsToJsonResponseConverter(
119-
json_interface=project_info["jsonInterface"],
120-
project_input_type=project_info["inputType"],
121-
)
122-
is_requesting_annotations = any("annotations." in element for element in fields)
123-
for asset in assets_gen:
124-
if "latestLabel.jsonResponse" in fields and asset.get("latestLabel"):
125-
converter.patch_label_json_response(
126-
asset, asset["latestLabel"], asset["latestLabel"]["annotations"]
127-
)
128-
if not is_requesting_annotations:
129-
asset["latestLabel"].pop("annotations")
130130

131-
if "labels.jsonResponse" in fields:
132-
for label in asset.get("labels", []):
133-
converter.patch_label_json_response(asset, label, label["annotations"])
131+
if fetch_annotations:
132+
converter = AnnotationsToJsonResponseConverter(
133+
json_interface=project_info["jsonInterface"],
134+
project_input_type=project_info["inputType"],
135+
)
136+
is_requesting_annotations = any("annotations." in element for element in fields)
137+
for asset in assets_gen:
138+
if "latestLabel.jsonResponse" in fields and asset.get("latestLabel"):
139+
converter.patch_label_json_response(
140+
asset, asset["latestLabel"], asset["latestLabel"]["annotations"]
141+
)
134142
if not is_requesting_annotations:
135-
label.pop("annotations")
136-
yield asset
143+
asset["latestLabel"].pop("annotations")
144+
145+
if "labels.jsonResponse" in fields:
146+
for label in asset.get("labels", []):
147+
converter.patch_label_json_response(asset, label, label["annotations"])
148+
if not is_requesting_annotations:
149+
label.pop("annotations")
150+
yield asset
151+
else:
152+
yield from assets_gen
137153

138154
def count_assets(self, filters: AssetFilters) -> int:
139155
"""Send a GraphQL request calling countIssues resolver."""
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
"""HTTP JSON helpers for downloading JSON data from URLs."""
2+
3+
from kili.adapters.http_client import HttpClient
4+
from kili.core.helpers import is_url
5+
6+
7+
def load_json_from_link(link: str, http_client: HttpClient) -> dict:
8+
"""Load json from link.
9+
10+
Args:
11+
link: URL to download JSON from
12+
http_client: HttpClient instance with SSL verification already configured
13+
14+
Returns:
15+
Parsed JSON response as a dictionary, or empty dict if link is invalid
16+
"""
17+
if link == "" or not is_url(link):
18+
return {}
19+
20+
response = http_client.get(link, timeout=30)
21+
response.raise_for_status()
22+
return response.json()

src/kili/adapters/kili_api_gateway/label/formatters.py

Lines changed: 14 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2,15 +2,24 @@
22

33
import json
44

5+
from kili.adapters.http_client import HttpClient
6+
from kili.adapters.kili_api_gateway.helpers.http_json import load_json_from_link
7+
from kili.core.helpers import is_url
58
from kili.domain.types import ListOrTuple
69

710

8-
def load_label_json_fields(label: dict, fields: ListOrTuple[str]) -> dict:
11+
def load_label_json_fields(label: dict, fields: ListOrTuple[str], http_client: HttpClient) -> dict:
912
"""Load json fields of a label."""
1013
if "jsonResponse" in fields:
11-
try:
12-
label["jsonResponse"] = json.loads(label.get("jsonResponse", "{}"))
13-
except json.JSONDecodeError:
14-
label["jsonResponse"] = {}
14+
json_response_url = label.get("jsonResponseUrl")
15+
if json_response_url and is_url(json_response_url):
16+
label["jsonResponse"] = load_json_from_link(json_response_url, http_client)
17+
del label["jsonResponseUrl"]
18+
else:
19+
json_response_value = label.get("jsonResponse", "{}")
20+
try:
21+
label["jsonResponse"] = json.loads(json_response_value)
22+
except json.JSONDecodeError:
23+
label["jsonResponse"] = {}
1524

1625
return label

0 commit comments

Comments
 (0)