Skip to content

Commit 6080c03

Browse files
committed
feat(model):
- Improve Manifest and Step models - Apply extra=forbid configuration to all models - Implement submanifest support for Step fields. - Add "render_jsonschema" helper script.
1 parent 9b2d732 commit 6080c03

File tree

9 files changed

+278
-62
lines changed

9 files changed

+278
-62
lines changed

script/render_jsonschema.py

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
"""Loads the CM Service Manifest Models and generates JSONSchema files"""
2+
3+
import json
4+
from pathlib import Path
5+
6+
from lsst.cmservice.models.manifests import bps, butler, facility, lsst, steps, wms
7+
8+
9+
def main():
10+
for manifest in [
11+
bps.BpsSpec,
12+
butler.ButlerSpec,
13+
facility.FacilitySpec,
14+
lsst.LsstSpec,
15+
steps.StepSpec,
16+
wms.WmsSpec,
17+
]:
18+
manifest_schema = manifest.model_json_schema()
19+
manifest_title = manifest_schema["title"]
20+
Path(f"{manifest_title}.jsonschema").write_text(json.dumps(manifest_schema, indent=2))
21+
22+
23+
if __name__ == "__main__":
24+
main()

src/lsst/cmservice/models/manifest.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,5 +3,5 @@
33
from .manifests.butler import ButlerManifest as ButlerManifest
44
from .manifests.facility import FacilityManifest as FacilityManifest
55
from .manifests.lsst import LsstManifest as LsstManifest
6-
from .manifests.steps import GroupedStepManifest as GroupedStepManifest
6+
from .manifests.steps import StepManifest as StepManifest
77
from .manifests.wms import WmsManifest as WmsManifest

src/lsst/cmservice/models/manifests/__init__.py

Lines changed: 53 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,7 @@
1-
from pydantic import AliasChoices, BaseModel, Field
1+
from typing import Any, cast
2+
3+
from pydantic import AliasChoices, BaseModel, ConfigDict, Field, create_model
4+
from pydantic.alias_generators import to_snake
25

36
from ...common.enums import ManifestKind
47
from ...common.types import KindField
@@ -20,3 +23,52 @@ class LibraryManifest[SpecT](BaseModel):
2023
validation_alias=AliasChoices("spec", "configuration", "data"),
2124
serialization_alias="spec",
2225
)
26+
27+
28+
def model_title_generator(cls: type[BaseModel]) -> str:
29+
"""Given a pydantic Model, returns the name of the model in snake_case."""
30+
return to_snake(cls.__name__)
31+
32+
33+
class SubfieldManifest[T: BaseModel]:
34+
"""Create a variant of a Pydantic Model with all fields optional.
35+
36+
This class allows the use of a more restrictive model as a subfield in
37+
another model such that all fields in the submodel are "optional" in that
38+
they do not need to be supplied, but if they are they remain subject to
39+
any type restrictions.
40+
41+
Parameters
42+
----------
43+
T : type[BaseModel]
44+
The pydantic BaseModel type to enable for Subfield use.
45+
46+
Returns
47+
-------
48+
type[T]
49+
A new model class with the same config and fields as the input model,
50+
but all fields are made optional (i.e., may be missing but not None).
51+
52+
Notes
53+
-----
54+
- The original model's configuration is preserved.
55+
- Nested submodels are handled correctly.
56+
- Fields remain strongly typed - if present, they must match the original
57+
type.
58+
"""
59+
60+
def __class_getitem__(cls, model: type[T]) -> type[T]:
61+
fields: dict[str, Any] = {
62+
field_name: (field_info.annotation, None) for field_name, field_info in model.model_fields.items()
63+
}
64+
subfield_model = create_model(f"{model.__name__}SubField", __config__=model.model_config, **fields)
65+
subfield_model.model_rebuild()
66+
return cast(type[T], subfield_model)
67+
68+
69+
SPEC_CONFIG = ConfigDict(
70+
model_title_generator=model_title_generator,
71+
extra="forbid",
72+
str_strip_whitespace=True,
73+
)
74+
"""A common pydantic model configuration for use with multiple models."""

src/lsst/cmservice/models/manifests/bps.py

Lines changed: 20 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88

99
from pydantic import Field
1010

11-
from . import LibraryManifest, ManifestSpec
11+
from . import SPEC_CONFIG, LibraryManifest, ManifestSpec
1212

1313

1414
class BpsSpec(ManifestSpec):
@@ -17,37 +17,35 @@ class BpsSpec(ManifestSpec):
1717
a campaign template.
1818
"""
1919

20-
pipeline_yaml: str | None = Field(default=None)
20+
model_config = SPEC_CONFIG
21+
pipeline_yaml: str | None = Field(
22+
default=None,
23+
description="The absolute path to a Pipeline YAML specification file with optional anchor. "
24+
"The path must begin with a `/` or a `${...}` environment variable.",
25+
pattern="^(/|\\$\\{.*\\})(.*)(\\.yaml)(#.*)?$",
26+
)
2127
variables: dict[str, str] | None = Field(
2228
default=None,
23-
description=(
24-
"A mapping of name-value string pairs used to define addtional "
25-
"top-level BPS substitution variables. Note that the values are quoted in the "
26-
"output."
27-
),
29+
description="A mapping of name-value string pairs used to define addtional "
30+
"top-level BPS substitution variables. Note that the values are quoted in the "
31+
"output.",
2832
)
2933
include_files: list[str] | None = Field(default=None)
3034
literals: dict[str, Any] | None = Field(
3135
default=None,
32-
description=(
33-
"A mapping of arbitrary top-level mapping sections to be added as additional literal YAML, "
34-
"e.g., `finalJob`."
35-
),
36+
description="A mapping of arbitrary top-level mapping sections to be added as additional "
37+
"literal YAML, e.g., `finalJob`.",
3638
)
3739
environment: dict[str, str] | None = Field(
3840
default=None,
39-
description=(
40-
"A mapping of name-value string pairs used to defined additional "
41-
"values under the `environment` heading."
42-
),
41+
description="A mapping of name-value string pairs used to defined additional "
42+
"values under the `environment` heading.",
4343
)
4444
payload: dict[str, str] | None = Field(
4545
default=None,
46-
description=(
47-
"A mapping of name-value string pairs used to define BPS payload "
48-
"options. Note that these values are generated from other configuration "
49-
"sources at runtime."
50-
),
46+
description="A mapping of name-value string pairs used to define BPS payload "
47+
"options. Note that these values are generated from other configuration "
48+
"sources at runtime.",
5149
)
5250
extra_init_options: str | None = Field(
5351
default=None, description="Options added to the end of pipetaskinit"
@@ -61,9 +59,8 @@ class BpsSpec(ManifestSpec):
6159
extra_update_qgraph_options: str | None = Field(default=None)
6260
clustering: dict[str, Any] | None = Field(
6361
default=None,
64-
description=(
65-
"A mapping of clustering directives, added as literal YAML under the `clustering` heading."
66-
),
62+
description="A mapping of clustering directives, added as literal YAML "
63+
"under the `clustering` heading.",
6764
)
6865

6966

src/lsst/cmservice/models/manifests/butler.py

Lines changed: 12 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -10,23 +10,15 @@
1010

1111
from pydantic import AliasChoices, BaseModel, Field
1212

13-
from . import LibraryManifest, ManifestSpec
14-
15-
16-
class ButlerSpec(ManifestSpec):
17-
"""Spec model for a Butler Manifest."""
18-
19-
collections: ButlerCollectionsSpec
20-
predicates: Sequence[str] = Field(default_factory=list)
21-
repo: str = Field(description="Name of a Butler known to the application's Butler Factory.")
22-
include_files: list[str] | None = Field(default=None)
13+
from . import SPEC_CONFIG, LibraryManifest, ManifestSpec
2314

2415

2516
class ButlerCollectionsSpec(BaseModel):
2617
"""Specification for the definition of Butler collections used throughout
2718
a campaign. This model is part of the "spec" of a Butler Library Manifest.
2819
"""
2920

21+
model_config = SPEC_CONFIG
3022
campaign_input: list[str] = Field(
3123
description="The campaign source collection",
3224
default_factory=list,
@@ -52,4 +44,14 @@ class ButlerCollectionsSpec(BaseModel):
5244
)
5345

5446

47+
class ButlerSpec(ManifestSpec):
48+
"""Spec model for a Butler Manifest."""
49+
50+
model_config = SPEC_CONFIG
51+
collections: ButlerCollectionsSpec
52+
predicates: Sequence[str] = Field(default_factory=list)
53+
repo: str = Field(description="Name of a Butler known to the application's Butler Factory.")
54+
include_files: list[str] | None = Field(default=None)
55+
56+
5557
class ButlerManifest(LibraryManifest[ButlerSpec]): ...

src/lsst/cmservice/models/manifests/facility.py

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,10 +4,19 @@
44

55
from __future__ import annotations
66

7-
from . import LibraryManifest, ManifestSpec
7+
from typing import Literal
88

9+
from pydantic import Field
910

10-
class FacilitySpec(ManifestSpec): ...
11+
from . import SPEC_CONFIG, LibraryManifest, ManifestSpec
12+
13+
14+
class FacilitySpec(ManifestSpec):
15+
model_config = SPEC_CONFIG
16+
facility: Literal["USDF", "IN2P3", "LANC", "RAL"] = Field(
17+
default="USDF",
18+
description="The name of the processing facility.",
19+
)
1120

1221

1322
class FacilityManifest(LibraryManifest[FacilitySpec]): ...

src/lsst/cmservice/models/manifests/lsst.py

Lines changed: 21 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,25 +9,44 @@
99

1010
from pydantic import Field
1111

12-
from . import LibraryManifest, ManifestSpec
12+
from . import SPEC_CONFIG, LibraryManifest, ManifestSpec
1313

1414

1515
class LsstSpec(ManifestSpec):
1616
"""Spec model for an LSST Manifest."""
1717

18+
model_config = SPEC_CONFIG
1819
lsst_version: str = Field(default="w_latest", description="LSST Stack version")
1920
lsst_distrib_dir: str = Field(description="Absolute path to a stack distribution location")
2021
prepend: str | None = Field(
2122
default=None,
2223
description="A newline-delimited string of shell actions to execute prior to stack setup.",
2324
)
25+
custom_lsst_setup: str | None = Field(
26+
default=None,
27+
description="A `\n`-delimited string of optional commands to be added to any Bash script that sets "
28+
"up the LSST Stack, to be executed verbatim *after* EUPS setup but *before* the payload command. "
29+
"Can be used to customize the Stack with EUPS, for example.",
30+
)
2431
append: str | None = Field(
2532
default=None,
26-
description="A newline-delimited string of shell actions to execute after to stack setup.",
33+
description="A newline-delimited string of shell actions to execute after the payload command.",
2734
)
2835
environment: Mapping = Field(
2936
default_factory=dict, description="A mapping of environment variables to set prior to stack setup"
3037
)
38+
ticket: str | None = Field(
39+
default=None,
40+
description="An optional JIRA ticket number associated with the campaign.",
41+
)
42+
description: str | None = Field(
43+
default=None,
44+
description="An optional description of the campaign.",
45+
)
46+
campaign: str | None = Field(default=None, description="An optional name for the campaign.")
47+
project: str | None = Field(
48+
default=None, description="An optional project name or identifier associated with the campaign."
49+
)
3150

3251

3352
class LsstManifest(LibraryManifest[LsstSpec]): ...

0 commit comments

Comments
 (0)