Skip to content

Commit 78565da

Browse files
authored
Merge branch 'master' into obfuscate_aws_cred
2 parents c22730f + fd9641d commit 78565da

File tree

122 files changed

+1931
-643
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

122 files changed

+1931
-643
lines changed

.env-devel

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -377,7 +377,7 @@ TRACING_OPENTELEMETRY_COLLECTOR_BATCH_SIZE=2
377377
TRACING_OPENTELEMETRY_COLLECTOR_ENDPOINT=http://opentelemetry-collector
378378
TRACING_OPENTELEMETRY_COLLECTOR_EXPORTER_ENDPOINT=http://jaeger:4318
379379
TRACING_OPENTELEMETRY_COLLECTOR_PORT=4318
380-
TRACING_OPENTELEMETRY_COLLECTOR_SAMPLING_PERCENTAGE=100
380+
TRACING_OPENTELEMETRY_SAMPLING_PROBABILITY=1.0
381381
TRAEFIK_SIMCORE_ZONE=internal_simcore_stack
382382
TRASH_RETENTION_DAYS=7
383383
TWILIO_ACCOUNT_SID=DUMMY

packages/models-library/src/models_library/api_schemas_catalog/services.py

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
from pydantic import ConfigDict, Field, HttpUrl, NonNegativeInt
77
from pydantic.config import JsonDict
88

9+
from ..batch_operations import BatchGetEnvelope
910
from ..boot_options import BootOptions
1011
from ..emails import LowerCaseEmailStr
1112
from ..groups import GroupID
@@ -420,6 +421,46 @@ class MyServiceGet(CatalogOutputSchema):
420421
my_access_rights: ServiceGroupAccessRightsV2
421422

422423

424+
class MyServicesRpcBatchGet(
425+
CatalogOutputSchema,
426+
BatchGetEnvelope[MyServiceGet, tuple[ServiceKey, ServiceVersion]],
427+
):
428+
"""Result for batch get user services operations"""
429+
430+
@staticmethod
431+
def _update_json_schema_extra(schema: JsonDict) -> None:
432+
missing: Any = [("simcore/services/comp/itis/sleeper", "100.2.3")]
433+
schema.update(
434+
{
435+
"examples": [
436+
{
437+
"found_items": [
438+
{
439+
"key": missing[0][0],
440+
"release": {
441+
"version": "2.2.1",
442+
"version_display": "Winter Release",
443+
"released": "2026-07-21T15:00:00",
444+
},
445+
"owner": 42,
446+
"my_access_rights": {
447+
"execute": True,
448+
"write": False,
449+
},
450+
}
451+
],
452+
"missing_identifiers": missing,
453+
}
454+
]
455+
}
456+
)
457+
458+
model_config = ConfigDict(
459+
extra="forbid",
460+
json_schema_extra=_update_json_schema_extra,
461+
)
462+
463+
423464
class ServiceListFilters(Filters):
424465
service_type: Annotated[
425466
ServiceType | None,

packages/models-library/src/models_library/api_schemas_webserver/projects_nodes.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
from ..projects_nodes import InputID, InputsDict, PartialNode
1313
from ..projects_nodes_io import NodeID
1414
from ..services import ServiceKey, ServicePortKey, ServiceVersion
15+
from ..services_base import ServiceKeyVersion
1516
from ..services_enums import ServiceState
1617
from ..services_history import ServiceRelease
1718
from ..services_resources import ServiceResourcesDict
@@ -225,3 +226,9 @@ class NodeServiceGet(OutputSchema):
225226
class ProjectNodeServicesGet(OutputSchema):
226227
project_uuid: ProjectID
227228
services: list[NodeServiceGet]
229+
missing: Annotated[
230+
list[ServiceKeyVersion] | None,
231+
Field(
232+
description="List of services defined in the project but that were not found in the catalog"
233+
),
234+
] = None
Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
from typing import Annotated, Generic, TypeVar
2+
3+
from common_library.basic_types import DEFAULT_FACTORY
4+
from pydantic import BaseModel, BeforeValidator, Field, TypeAdapter
5+
6+
ResourceT = TypeVar("ResourceT")
7+
IdentifierT = TypeVar("IdentifierT")
8+
SchemaT = TypeVar("SchemaT")
9+
10+
11+
def _deduplicate_preserving_order(identifiers: list[IdentifierT]) -> list[IdentifierT]:
12+
"""Remove duplicates while preserving order of first occurrence."""
13+
return list(dict.fromkeys(identifiers))
14+
15+
16+
def create_batch_ids_validator(identifier_type: type[IdentifierT]) -> TypeAdapter:
17+
"""Create a TypeAdapter for validating batch identifiers.
18+
19+
This validator ensures:
20+
- At least one identifier is provided (empty list is invalid for batch operations)
21+
- Duplicates are removed while preserving order
22+
23+
Args:
24+
identifier_type: The type of identifiers in the batch
25+
26+
Returns:
27+
TypeAdapter configured for the specific identifier type
28+
"""
29+
return TypeAdapter(
30+
Annotated[
31+
list[identifier_type], # type: ignore[valid-type]
32+
BeforeValidator(_deduplicate_preserving_order),
33+
Field(
34+
min_length=1,
35+
description="List of identifiers to batch process. Empty list is not allowed for batch operations.",
36+
),
37+
]
38+
)
39+
40+
41+
class BatchGetEnvelope(BaseModel, Generic[ResourceT, IdentifierT]):
42+
"""Generic envelope model for batch-get operations that can contain partial results.
43+
44+
This model represents the result of a batch operation where some items might be found
45+
and others might be missing. It enforces that at least one item must be found,
46+
as an empty batch operation is considered a client error.
47+
"""
48+
49+
found_items: Annotated[
50+
list[ResourceT],
51+
Field(
52+
min_length=1,
53+
description="List of successfully retrieved items. Must contain at least one item.",
54+
),
55+
]
56+
missing_identifiers: Annotated[
57+
list[IdentifierT],
58+
Field(
59+
default_factory=list,
60+
description="List of identifiers for items that were not found",
61+
),
62+
] = DEFAULT_FACTORY
Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,108 @@
1+
import pytest
2+
from faker import Faker
3+
from models_library.api_schemas_webserver._base import (
4+
OutputSchema as WebServerOutputSchema,
5+
)
6+
from models_library.api_schemas_webserver.projects import (
7+
ProjectGet,
8+
)
9+
from models_library.batch_operations import BatchGetEnvelope, create_batch_ids_validator
10+
from models_library.generics import Envelope
11+
from models_library.projects import ProjectID
12+
from pydantic import TypeAdapter, ValidationError
13+
14+
15+
@pytest.mark.parametrize(
16+
"identifier_type,input_ids,expected_output,should_raise",
17+
[
18+
# Valid cases - successful validation
19+
pytest.param(
20+
str, ["a", "b", "c"], ["a", "b", "c"], False, id="str_valid_no_duplicates"
21+
),
22+
pytest.param(int, [1, 2, 3], [1, 2, 3], False, id="int_valid_no_duplicates"),
23+
pytest.param(
24+
tuple,
25+
[("a", 1), ("b", 2)],
26+
[("a", 1), ("b", 2)],
27+
False,
28+
id="tuple_valid_no_duplicates",
29+
),
30+
# Deduplication cases - preserving order
31+
pytest.param(
32+
str, ["a", "b", "a", "c"], ["a", "b", "c"], False, id="str_with_duplicates"
33+
),
34+
pytest.param(int, [1, 2, 1, 3, 2], [1, 2, 3], False, id="int_with_duplicates"),
35+
pytest.param(
36+
tuple,
37+
[("a", 1), ("b", 2), ("a", 1)],
38+
[("a", 1), ("b", 2)],
39+
False,
40+
id="tuple_with_duplicates",
41+
),
42+
# Single item cases
43+
pytest.param(str, ["single"], ["single"], False, id="str_single_item"),
44+
pytest.param(int, [42], [42], False, id="int_single_item"),
45+
# Edge case - all duplicates resolve to single item
46+
pytest.param(
47+
str, ["same", "same", "same"], ["same"], False, id="str_all_duplicates"
48+
),
49+
# Error cases - empty list should raise ValidationError
50+
pytest.param(str, [], None, True, id="str_empty_list_error"),
51+
pytest.param(int, [], None, True, id="int_empty_list_error"),
52+
pytest.param(tuple, [], None, True, id="tuple_empty_list_error"),
53+
],
54+
)
55+
def test_create_batch_ids_validator(
56+
identifier_type, input_ids, expected_output, should_raise
57+
):
58+
validator = create_batch_ids_validator(identifier_type)
59+
60+
if should_raise:
61+
with pytest.raises(ValidationError) as exc_info:
62+
validator.validate_python(input_ids)
63+
64+
# Verify the error is about minimum length
65+
assert "at least 1" in str(exc_info.value).lower()
66+
else:
67+
result = validator.validate_python(input_ids)
68+
assert result == expected_output
69+
assert len(result) >= 1 # Ensure minimum length constraint
70+
# Verify order preservation by checking first occurrence positions
71+
if len(set(input_ids)) != len(input_ids): # Had duplicates
72+
original_first_positions = {
73+
item: input_ids.index(item) for item in set(input_ids)
74+
}
75+
# Items should appear in the same relative order as their first occurrence
76+
sorted_by_original = sorted(
77+
result, key=lambda x: original_first_positions[x]
78+
)
79+
assert result == sorted_by_original
80+
81+
82+
def test_composing_schemas_for_batch_operations(faker: Faker):
83+
84+
# inner schema model
85+
class WebServerProjectBatchGetSchema(
86+
WebServerOutputSchema, BatchGetEnvelope[ProjectGet, ProjectID]
87+
): ...
88+
89+
some_projects = ProjectGet.model_json_schema()["examples"]
90+
91+
# response model
92+
response_model = Envelope[WebServerProjectBatchGetSchema].model_validate(
93+
{
94+
# NOTE: how camelcase (from WebServerOutputSchema.model_config) applies here
95+
"data": {
96+
"foundItems": some_projects,
97+
"missingIdentifiers": [ProjectID(faker.uuid4())],
98+
}
99+
}
100+
)
101+
102+
assert response_model.data is not None
103+
104+
assert response_model.data.found_items == TypeAdapter(
105+
list[ProjectGet]
106+
).validate_python(some_projects)
107+
108+
assert len(response_model.data.missing_identifiers) == 1

packages/service-library/src/servicelib/aiohttp/monitoring.py

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@
2020
from ..prometheus_metrics import (
2121
PrometheusMetrics,
2222
get_prometheus_metrics,
23+
record_asyncio_event_looop_metrics,
2324
record_request_metrics,
2425
record_response_metrics,
2526
)
@@ -33,15 +34,15 @@
3334

3435
def get_collector_registry(app: web.Application) -> CollectorRegistry:
3536
metrics = app[PROMETHEUS_METRICS_APPKEY]
36-
assert isinstance(metrics, PrometheusMetrics) # nosec
3737
return metrics.registry
3838

3939

4040
async def metrics_handler(request: web.Request):
41-
registry = get_collector_registry(request.app)
41+
metrics = request.app[PROMETHEUS_METRICS_APPKEY]
42+
await record_asyncio_event_looop_metrics(metrics)
4243

4344
# NOTE: Cannot use ProcessPoolExecutor because registry is not pickable
44-
result = await request.loop.run_in_executor(None, generate_latest, registry)
45+
result = await request.loop.run_in_executor(None, generate_latest, metrics.registry)
4546
response = web.Response(body=result)
4647
response.content_type = CONTENT_TYPE_LATEST
4748
return response

0 commit comments

Comments
 (0)