Skip to content

Commit b28bd48

Browse files
authored
Merge branch 'main' into kafka-regions
2 parents 78c7328 + 3fd5ebd commit b28bd48

12 files changed

Lines changed: 166 additions & 36 deletions

File tree

client/qiskit_serverless/core/client.py

Lines changed: 13 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -157,8 +157,12 @@ def upload(self, program: QiskitFunction) -> Optional[RunnableQiskitFunction]:
157157
"""Uploads program."""
158158

159159
@abstractmethod
160-
def functions(self, **kwargs) -> List[RunnableQiskitFunction]:
161-
"""Returns list of available programs."""
160+
def functions(self, provider: Optional[str] = None, **kwargs) -> List[RunnableQiskitFunction]:
161+
"""Returns list of available programs.
162+
163+
Args:
164+
provider: if given, only functions belonging to this provider are returned.
165+
"""
162166

163167
@abstractmethod
164168
def function(self, title: str, provider: Optional[str] = None) -> Optional[RunnableQiskitFunction]:
@@ -174,12 +178,16 @@ def get(self, title: str, provider: Optional[str] = None) -> Optional[RunnableQi
174178
)
175179
return self.function(title, provider=provider)
176180

177-
def list(self, **kwargs) -> List[RunnableQiskitFunction]:
178-
"""Returns list of available programs."""
181+
def list(self, provider: Optional[str] = None, **kwargs) -> List[RunnableQiskitFunction]:
182+
"""Returns list of available programs.
183+
184+
Args:
185+
provider: if given, only functions belonging to this provider are returned.
186+
"""
179187
warnings.warn(
180188
"`list` method has been deprecated. "
181189
"And will be removed in future releases. "
182190
"Please, use `get_functions` instead.",
183191
DeprecationWarning,
184192
)
185-
return self.functions(**kwargs)
193+
return self.functions(provider=provider, **kwargs)

client/qiskit_serverless/core/clients/serverless_client.py

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -611,13 +611,21 @@ def upload(self, program: QiskitFunction) -> Optional[RunnableQiskitFunction]:
611611
return function_uploaded
612612

613613
@_trace_functions("list")
614-
def functions(self, **kwargs) -> List[RunnableQiskitFunction]:
615-
"""Returns list of available functions."""
614+
def functions(self, provider: Optional[str] = None, **kwargs) -> List[RunnableQiskitFunction]:
615+
"""Returns list of available functions.
616+
617+
Args:
618+
provider: if given, only functions belonging to this provider are returned,
619+
e.g. ``functions(provider="q-ctrl")``.
620+
"""
621+
params = dict(kwargs)
622+
if provider:
623+
params["provider"] = provider
616624
response_data = safe_json_request_as_list(
617625
request=lambda: requests.get(
618626
f"{self.host}/api/{self.version}/programs",
619627
headers=get_headers(token=self.token, instance=self.instance, channel=self.channel),
620-
params=kwargs,
628+
params=params,
621629
timeout=REQUESTS_TIMEOUT,
622630
)
623631
)

client/requirements-dev.txt

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ pylint>=4.0.8
33
nbqa>=1.9.1
44
treon>=0.1.4
55
pytest>=9.1.1
6-
pytest-randomly>=4.1.0
6+
pytest-randomly>=5.0.0
77
mypy>=2.3.1
88
mypy-extensions>=1.1.0
99
jupyter-sphinx>=0.5.3
@@ -16,6 +16,6 @@ black[jupyter]~=26.5
1616
requests-mock>=1.12.1
1717
# new versions of testcontainers don't support docker compose
1818
testcontainers>=4.15.0
19-
tox>=4.61.1
19+
tox>=4.61.2
2020
pre-commit>=4.6.2
2121
setuptools>=84.0.0

client/tests/core/test_serverless_client_functions.py

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -218,6 +218,20 @@ def test_functions_passes_query_parameters(self, mock_get, mock_client):
218218
call_kwargs = mock_get.call_args[1]
219219
assert call_kwargs["params"] == {"limit": 5, "offset": 10, "provider": "test-provider"}
220220

221+
@patch("qiskit_serverless.core.clients.serverless_client.requests.get")
222+
def test_functions_passes_provider_filter(self, mock_get, mock_client):
223+
"""functions(provider=...) forwards the provider as a query parameter."""
224+
mock_response = Mock()
225+
mock_response.ok = True
226+
mock_response.text = "[]"
227+
mock_response.json.return_value = []
228+
mock_get.return_value = mock_response
229+
230+
mock_client.functions(provider="q-ctrl")
231+
232+
mock_get.assert_called_once()
233+
assert mock_get.call_args[1]["params"] == {"provider": "q-ctrl"}
234+
221235
@patch("qiskit_serverless.core.clients.serverless_client.requests.get")
222236
def test_functions_returns_empty_list_when_no_functions(self, mock_get, mock_client):
223237
"""functions() returns an empty list when no functions are available."""

gateway/api/use_cases/programs/list.py

Lines changed: 21 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -20,26 +20,32 @@ def execute(
2020
user: AbstractUser,
2121
accessible_functions: FunctionAccessResult,
2222
type_filter: str | None,
23+
provider: str | None = None,
2324
) -> list[Function]:
24-
"""Return functions the user can see, filtered by type_filter."""
25+
"""Return functions the user can see, filtered by type_filter and provider.
26+
27+
When provider is given, results are narrowed to that provider's functions. The
28+
narrowing is applied after permission scoping, so it can only reduce the set the
29+
user is already allowed to see -- an unknown or inaccessible provider yields [].
30+
"""
2531
if type_filter == TypeFilter.SERVERLESS:
26-
return list(Function.objects.user_functions(user))
27-
28-
if type_filter == TypeFilter.CATALOG:
29-
return list(
30-
Function.objects.provider_functions().with_permission(
31-
user,
32-
accessible_functions=accessible_functions,
33-
legacy_permission_name=RUN_PROGRAM_PERMISSION,
34-
permission=PLATFORM_PERMISSION_READ,
35-
)
32+
queryset = Function.objects.user_functions(user)
33+
elif type_filter == TypeFilter.CATALOG:
34+
queryset = Function.objects.provider_functions().with_permission(
35+
user,
36+
accessible_functions=accessible_functions,
37+
legacy_permission_name=RUN_PROGRAM_PERMISSION,
38+
permission=PLATFORM_PERMISSION_READ,
3639
)
37-
38-
return list(
39-
Function.objects.with_permission(
40+
else:
41+
queryset = Function.objects.with_permission(
4042
user,
4143
accessible_functions=accessible_functions,
4244
legacy_permission_name=VIEW_PROGRAM_PERMISSION,
4345
permission=PLATFORM_PERMISSION_READ,
4446
)
45-
)
47+
48+
if provider:
49+
queryset = queryset.filter(provider__name=provider)
50+
51+
return list(queryset)

gateway/api/v1/views/programs/list.py

Lines changed: 17 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,13 @@ class Meta:
5858
type=openapi.TYPE_STRING,
5959
required=False,
6060
),
61+
openapi.Parameter(
62+
"provider",
63+
openapi.IN_QUERY,
64+
description="Return only functions belonging to this provider",
65+
type=openapi.TYPE_STRING,
66+
required=False,
67+
),
6168
],
6269
responses={status.HTTP_200_OK: OutputSerializer(many=True)},
6370
)
@@ -67,15 +74,22 @@ class Meta:
6774
def list_programs(request: Request) -> Response:
6875
"""List Qiskit Functions accessible to the authenticated user."""
6976
type_filter = request.query_params.get("filter")
77+
provider = request.query_params.get("provider")
7078
user = cast(AbstractUser, request.user)
7179
accessible_functions = cast(FunctionAccessResult, request.auth.accessible_functions)
7280
logger.info(
73-
"[programs-list] user_id=%s filter=%s accessible_functions=%s",
81+
"[programs-list] user_id=%s filter=%s provider=%s accessible_functions=%s",
7482
user.id,
7583
type_filter,
84+
provider,
7685
accessible_functions,
7786
)
7887

79-
functions = ListFunctionsUseCase().execute(user, accessible_functions, type_filter)
80-
logger.info("[programs-list] user_id=%s filter=%s | Functions listed ok", user.id, type_filter)
88+
functions = ListFunctionsUseCase().execute(user, accessible_functions, type_filter, provider)
89+
logger.info(
90+
"[programs-list] user_id=%s filter=%s provider=%s | Functions listed ok",
91+
user.id,
92+
type_filter,
93+
provider,
94+
)
8195
return Response(OutputSerializer(functions, many=True).data)

gateway/requirements-dev.txt

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,6 @@ requests-mock>=1.12.1
99
coverage>=7.16.0
1010
pytest-cov>=7.1.0
1111
responses>=0.26.3
12-
tox>=4.61.1
12+
tox>=4.61.2
1313
pre-commit>=4.6.2
14-
import-linter>=2.14
14+
import-linter>=2.15

gateway/requirements.txt

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -25,12 +25,12 @@ django-cors-headers>=4.9.0, <5
2525
whitenoise>=6.12.0, <7
2626
objsize>=0.8.0, <1
2727
psutil>=7.2.2, <8
28-
ibm-platform-services>=0.77.0, <1
28+
ibm-platform-services>=0.77.1, <1
2929
python-magic>=0.4.27, <1
3030
python-json-logger>=4.2.0
3131
PyJWT>=2.13.0, <3
3232
ibm-cos-sdk>=2.16.2, <3
33-
ibm-cloud-sdk-core>=3.26.0, <4
33+
ibm-cloud-sdk-core>=3.26.1, <4
3434
jsonschema>=4.26.0,<5
3535
confluent-kafka>=2.15.0,<3
3636
mozilla-django-oidc>=5.0.2, <6

gateway/tests/api/test_v1_program.py

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -330,6 +330,38 @@ def test_provider_programs_serverless_list(self):
330330
assert len(programs_response.data) == 1
331331
assert programs_response.data[0].get("title") == "Program"
332332

333+
def test_provider_programs_filtered_by_provider(self):
334+
"""Tests the provider query param narrows the catalog list to one provider."""
335+
336+
user = TestUtils.authorize_client(user="test_user_4", client=self.client)
337+
TestUtils.get_or_create_group(group="runner", permissions=[self.runner_permission])
338+
TestUtils.add_user_to_group(user=user, group="runner")
339+
340+
# Two accessible provider functions under different providers.
341+
TestUtils.create_program(
342+
program_title="Ibm-Program",
343+
author="test_user_3",
344+
provider="ibm",
345+
instances=["runner"],
346+
)
347+
TestUtils.create_program(
348+
program_title="QCtrl-Program",
349+
author="test_user_3",
350+
provider="q-ctrl",
351+
instances=["runner"],
352+
)
353+
354+
programs_response = self.client.get(
355+
reverse("v1:programs-list"),
356+
{"filter": "catalog", "provider": "q-ctrl"},
357+
format="json",
358+
)
359+
360+
assert programs_response.status_code == status.HTTP_200_OK
361+
assert len(programs_response.data) == 1
362+
assert programs_response.data[0].get("title") == "QCtrl-Program"
363+
assert programs_response.data[0].get("provider") == "q-ctrl"
364+
333365
def test_run(self):
334366
"""Tests run existing authorized."""
335367

gateway/tests/api/use_cases/programs/test_list.py

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,9 @@
44
from django.contrib.auth.models import User
55

66
from api.use_cases.programs.list import ListFunctionsUseCase
7+
from core.domain.authorization.function_access_entry import FunctionAccessEntry
78
from core.domain.authorization.function_access_result import FunctionAccessResult
9+
from core.domain.business_models import BusinessModel
810
from core.models import Program, Provider, PLATFORM_PERMISSION_READ
911
from tests.utils import create_function_access_result
1012

@@ -71,3 +73,39 @@ def test_empty_list_when_no_functions_exist(self, user):
7173
result = ListFunctionsUseCase().execute(user, accessible, None)
7274

7375
assert result == []
76+
77+
def test_provider_filter_narrows_catalog_to_that_provider(self, user):
78+
provider_a = Provider.objects.create(name="provider-a")
79+
provider_b = Provider.objects.create(name="provider-b")
80+
Program.objects.create(title="fn-a", author=user, provider=provider_a)
81+
Program.objects.create(title="fn-b", author=user, provider=provider_b)
82+
accessible = FunctionAccessResult(
83+
use_legacy_authorization=False,
84+
functions=[
85+
FunctionAccessEntry(
86+
provider_name="provider-a",
87+
function_title="fn-a",
88+
business_model=BusinessModel.SUBSIDIZED,
89+
permissions={PLATFORM_PERMISSION_READ},
90+
),
91+
FunctionAccessEntry(
92+
provider_name="provider-b",
93+
function_title="fn-b",
94+
business_model=BusinessModel.SUBSIDIZED,
95+
permissions={PLATFORM_PERMISSION_READ},
96+
),
97+
],
98+
)
99+
100+
result = ListFunctionsUseCase().execute(user, accessible, "catalog", provider="provider-a")
101+
102+
assert [f.title for f in result] == ["fn-a"]
103+
104+
def test_provider_filter_does_not_bypass_permissions(self, user, provider):
105+
# The function exists under the provider, but the user has no access to it.
106+
Program.objects.create(title="provider-fn", author=user, provider=provider)
107+
accessible = FunctionAccessResult(use_legacy_authorization=False, functions=[])
108+
109+
result = ListFunctionsUseCase().execute(user, accessible, "catalog", provider="my-provider")
110+
111+
assert result == []

0 commit comments

Comments
 (0)