Skip to content

Commit 1ac5803

Browse files
authored
πŸ› ✨ Is353/meta func command and fixes (ITISFoundation#2943)
1 parent ef38c09 commit 1ac5803

File tree

34 files changed

+862
-268
lines changed

34 files changed

+862
-268
lines changed

β€Ž.vscode/settings.template.jsonβ€Ž

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,11 +7,13 @@
77
"files.associations": {
88
".*rc": "ini",
99
".env*": "ini",
10-
"Dockerfile*": "dockerfile",
11-
"**/requirements/*.txt": "pip-requirements",
1210
"**/requirements/*.in": "pip-requirements",
11+
"**/requirements/*.txt": "pip-requirements",
12+
"*logs.txt": "log",
13+
"*.logs*": "log",
1314
"*Makefile": "makefile",
14-
"*.cwl": "yaml"
15+
"docker-compose*.yml": "dockercompose",
16+
"Dockerfile*": "dockerfile"
1517
},
1618
"files.eol": "\n",
1719
"files.exclude": {

β€Žapi/specs/common/schemas/services.yamlβ€Ž

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,14 @@ components:
4949
vcs_url:
5050
type: string
5151

52+
container_spec:
53+
type: object
54+
properties:
55+
command:
56+
type: array
57+
items:
58+
type: string
59+
5260
ServiceExtrasEnveloped:
5361
type: object
5462
required:

β€Žpackages/models-library/src/models_library/service_settings_labels.pyβ€Ž

Lines changed: 40 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
from enum import Enum
55
from functools import cached_property
66
from pathlib import Path
7-
from typing import Any, Dict, List, Optional
7+
from typing import Any, Dict, Literal, Optional
88

99
from pydantic import BaseModel, Extra, Field, Json, PrivateAttr, validator
1010

@@ -16,10 +16,39 @@ class _BaseConfig:
1616
keep_untouched = (cached_property,)
1717

1818

19+
class ContainerSpec(BaseModel):
20+
"""Implements entries that can be overriden for https://docs.docker.com/engine/api/v1.41/#operation/ServiceCreate
21+
request body: TaskTemplate -> ContainerSpec
22+
"""
23+
24+
command: list[str] = Field(
25+
alias="Command",
26+
description="Used to override the container's command",
27+
# NOTE: currently constraint to our use cases. Might mitigate some security issues.
28+
min_items=1,
29+
max_items=2,
30+
)
31+
32+
class Config(_BaseConfig):
33+
schema_extra = {
34+
"examples": [
35+
{"Command": ["executable"]},
36+
{"Command": ["executable", "subcommand"]},
37+
{"Command": ["ofs", "linear-regression"]},
38+
]
39+
}
40+
41+
1942
class SimcoreServiceSettingLabelEntry(BaseModel):
43+
"""These values are used to build the request body of https://docs.docker.com/engine/api/v1.41/#operation/ServiceCreate
44+
Specifically the section under ``TaskTemplate``
45+
"""
46+
2047
_destination_container: str = PrivateAttr()
2148
name: str = Field(..., description="The name of the service setting")
22-
setting_type: str = Field(
49+
setting_type: Literal[
50+
"string", "int", "integer", "number", "object", "ContainerSpec", "Resources"
51+
] = Field(
2352
...,
2453
description="The type of the service setting (follows Docker REST API naming scheme)",
2554
alias="type",
@@ -38,7 +67,13 @@ class Config(_BaseConfig):
3867
"type": "string",
3968
"value": ["node.platform.os == linux"],
4069
},
41-
# resources
70+
# SEE service_settings_labels.py::ContainerSpec
71+
{
72+
"name": "ContainerSpec",
73+
"type": "ContainerSpec",
74+
"value": {"Command": ["run"]},
75+
},
76+
# SEE service_resources.py::ResourceValue
4277
{
4378
"name": "Resources",
4479
"type": "Resources",
@@ -83,12 +118,12 @@ class PathMappingsLabel(BaseModel):
83118
...,
84119
description="folder path where the service is expected to provide all its outputs",
85120
)
86-
state_paths: List[Path] = Field(
121+
state_paths: list[Path] = Field(
87122
[],
88123
description="optional list of paths which contents need to be persisted",
89124
)
90125

91-
state_exclude: Optional[List[str]] = Field(
126+
state_exclude: Optional[list[str]] = Field(
92127
None,
93128
description="optional list unix shell rules used to exclude files from the state",
94129
)

β€Žpackages/models-library/src/models_library/services.pyβ€Ž

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
NOTE: to dump json-schema from CLI use
44
python -c "from models_library.services import ServiceDockerData as cls; print(cls.schema_json(indent=2))" > services-schema.json
55
"""
6+
67
from enum import Enum
78
from typing import Any, Optional, Union
89

@@ -22,7 +23,11 @@
2223
from .basic_regex import VERSION_RE
2324
from .boot_options import BootOption, BootOptions
2425
from .services_ui import Widget
25-
from .utils.json_schema import InvalidJsonSchema, jsonschema_validate_schema
26+
from .utils.json_schema import (
27+
InvalidJsonSchema,
28+
any_ref_key,
29+
jsonschema_validate_schema,
30+
)
2631

2732
# CONSTANTS -------------------------------------------
2833

@@ -203,6 +208,11 @@ def check_valid_json_schema(cls, v):
203208
if v is not None:
204209
try:
205210
jsonschema_validate_schema(schema=v)
211+
212+
if any_ref_key(v):
213+
# SEE https://github.com/ITISFoundation/osparc-simcore/issues/3030
214+
raise ValueError("Schemas with $ref are still not supported")
215+
206216
except InvalidJsonSchema as err:
207217
failed_path = "->".join(map(str, err.path))
208218
raise ValueError(

β€Žpackages/models-library/src/models_library/utils/json_schema.pyβ€Ž

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
# SEE possible enhancements in https://github.com/ITISFoundation/osparc-simcore/issues/3008
99

1010

11+
from collections.abc import Sequence
1112
from contextlib import suppress
1213
from copy import deepcopy
1314
from typing import Any, Dict, Tuple
@@ -82,9 +83,20 @@ def jsonschema_validate_schema(schema: Dict[str, Any]):
8283
return schema
8384

8485

86+
def any_ref_key(obj):
87+
if isinstance(obj, dict):
88+
return "$ref" in obj.keys() or any_ref_key(tuple(obj.values()))
89+
90+
if isinstance(obj, Sequence) and not isinstance(obj, str):
91+
return any(any_ref_key(v) for v in obj)
92+
93+
return False
94+
95+
8596
__all__: Tuple[str, ...] = (
97+
"any_ref_key",
8698
"InvalidJsonSchema",
87-
"JsonSchemaValidationError",
88-
"jsonschema_validate_schema",
8999
"jsonschema_validate_data",
100+
"jsonschema_validate_schema",
101+
"JsonSchemaValidationError",
90102
)
Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
# pylint: disable=redefined-outer-name
2+
# pylint: disable=unused-argument
3+
# pylint: disable=unused-variable
4+
5+
6+
import json
7+
from contextlib import suppress
8+
from importlib import import_module
9+
from inspect import getmembers, isclass
10+
from pathlib import Path
11+
from typing import Any, Iterable, Optional, Set, Tuple, Type
12+
13+
import models_library
14+
import pytest
15+
from models_library.utils.misc import extract_examples
16+
from pydantic import BaseModel, NonNegativeInt
17+
from pydantic.json import pydantic_encoder
18+
19+
20+
def iter_model_cls_examples(
21+
exclude: Optional[Set] = None,
22+
) -> Iterable[Tuple[str, Type[BaseModel], NonNegativeInt, Any]]:
23+
def _is_model_cls(cls) -> bool:
24+
with suppress(TypeError):
25+
# NOTE: issubclass( dict[models_library.services.ConstrainedStrValue, models_library.services.ServiceInput] ) raises TypeError
26+
return cls is not BaseModel and isclass(cls) and issubclass(cls, BaseModel)
27+
return False
28+
29+
exclude = exclude or set()
30+
31+
for filepath in Path(models_library.__file__).resolve().parent.glob("*.py"):
32+
if not filepath.name.startswith("_"):
33+
mod = import_module(f"models_library.{filepath.stem}")
34+
for name, model_cls in getmembers(mod, _is_model_cls):
35+
if name in exclude:
36+
continue
37+
# NOTE: this is part of utils.misc and is tested here
38+
examples = extract_examples(model_cls)
39+
for index, example in enumerate(examples):
40+
yield (name, model_cls, index, example)
41+
42+
43+
@pytest.mark.parametrize(
44+
"class_name, model_cls, example_index, test_example", iter_model_cls_examples()
45+
)
46+
def test_all_module_model_examples(
47+
class_name: str,
48+
model_cls: Type[BaseModel],
49+
example_index: NonNegativeInt,
50+
test_example: Any,
51+
):
52+
"""Automatically collects all BaseModel subclasses having examples and tests them against schemas"""
53+
print(
54+
f"test {example_index=} for {class_name=}:\n",
55+
json.dumps(test_example, default=pydantic_encoder, indent=2),
56+
"---",
57+
)
58+
model_instance = model_cls.parse_obj(test_example)
59+
assert isinstance(model_instance, model_cls)

β€Žpackages/models-library/tests/test_utils_json_schema.pyβ€Ž

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,9 +6,11 @@
66
from collections import deque
77

88
import pytest
9+
from faker import Faker
910
from models_library.utils.json_schema import (
1011
InvalidJsonSchema,
1112
JsonSchemaValidationError,
13+
any_ref_key,
1214
jsonschema_validate_data,
1315
jsonschema_validate_schema,
1416
)
@@ -117,3 +119,56 @@ def test_jsonschema_validate_data_succeed(valid_schema):
117119
"b": True,
118120
"s": "foo",
119121
}
122+
123+
124+
def test_resolve_content_schema(faker: Faker):
125+
#
126+
# https://python-jsonschema.readthedocs.io/en/stable/_modules/jsonschema/validators/#RefResolver.in_scope
127+
#
128+
import jsonschema
129+
import jsonschema.validators
130+
131+
with pytest.raises(jsonschema.ValidationError):
132+
jsonschema.validate(instance=[2, 3, 4], schema={"maxItems": 2})
133+
134+
schema_with_ref = {
135+
"title": "Complex_value",
136+
"$ref": "#/definitions/Complex",
137+
"definitions": {
138+
"Complex": {
139+
"title": "Complex",
140+
"type": "object",
141+
"properties": {
142+
"real": {"title": "Real", "default": 0, "type": "number"},
143+
"imag": {"title": "Imag", "default": 0, "type": "number"},
144+
},
145+
}
146+
},
147+
}
148+
149+
assert any_ref_key(schema_with_ref)
150+
151+
resolver = jsonschema.RefResolver.from_schema(schema_with_ref)
152+
assert resolver.resolution_scope == ""
153+
assert resolver.base_uri == ""
154+
155+
ref, schema_resolved = resolver.resolve(schema_with_ref["$ref"])
156+
157+
assert ref == "#/definitions/Complex"
158+
assert schema_resolved == {
159+
"title": "Complex",
160+
"type": "object",
161+
"properties": {
162+
"real": {"title": "Real", "default": 0, "type": "number"},
163+
"imag": {"title": "Imag", "default": 0, "type": "number"},
164+
},
165+
}
166+
167+
assert not any_ref_key(schema_resolved)
168+
169+
validator = jsonschema.validators.validator_for(schema_with_ref)
170+
validator.check_schema(schema_with_ref)
171+
172+
instance = {"real": faker.pyfloat()}
173+
assert validator(schema_with_ref).is_valid(instance)
174+
assert validator(schema_resolved).is_valid(instance)

β€Žpackages/models-library/tests/test_utils_misc.pyβ€Ž

Lines changed: 0 additions & 58 deletions
This file was deleted.

β€Žpackages/service-integration/src/service_integration/labels_annotations.pyβ€Ž

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,11 @@ def to_labels(
2929
else:
3030
label = _json_dumps({key: value}, sort_keys=False)
3131

32+
# NOTE: docker-compose env var interpolation gets confused with schema's '$ref' and
33+
# will replace it '$ref' with an empty string.
34+
if isinstance(label, str) and "$ref" in label:
35+
label = label.replace("$ref", "$$ref")
36+
3237
labels[f"{prefix_key}.{key}"] = label
3338

3439
return labels

0 commit comments

Comments
Β (0)