mirror of
https://github.com/galaxyproject/galaxy.git
synced 2026-09-24 16:30:27 +08:00
Harden workflow test JSON schema: titles, descriptions, optionality
Addresses the workflow-test schema usability regressions flagged against galaxy-tool-util-schema 0.4.0: - TestJob.doc: make optional (was incorrectly required). - TestJob + BaseTestOutputModel: port per-field descriptions from the 0.2.0 vendored schema. - Tests: name the top-level RootModel; emit $schema, title (GalaxyWorkflowTests), and description. - Job and assertion_list: promote type aliases to named RootModel subclasses so $defs keys aren't mangled. - Drop field_title_generator from StrictModel/_StrictJobModel/ ToolSourceBaseModel; add explicit title= on every Field and model_config(title=) on every test-schema model class. Codegen template emits titles on generated assertion models. - Fix codegen models_path to point at tool_util_models/assertions.py. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.7
parent
7dc5eab037
commit
9b225459b8
@@ -27,7 +27,7 @@ from galaxy.tool_util.verify.asserts import assertion_module_and_functions
|
||||
from galaxy.tool_util.verify.asserts._types import AssertionParameter as AssertionParameterAnnotation
|
||||
from galaxy.util.commands import shell
|
||||
|
||||
models_path = os.path.join(os.path.dirname(__file__), "assertion_models.py")
|
||||
models_path = os.path.join(os.path.dirname(__file__), "..", "..", "tool_util_models", "assertions.py")
|
||||
galaxy_xsd_path = os.path.join(os.path.dirname(__file__), "..", "xsd", "galaxy.xsd")
|
||||
|
||||
Children = Literal["allowed", "required", "forbidden"]
|
||||
@@ -114,17 +114,19 @@ def check_non_negative_if_int(v: typing.Any):
|
||||
|
||||
class base_{{assertion.name}}_model(AssertionModel):
|
||||
'''base model for {{assertion.name}} describing attributes.'''
|
||||
model_config = ConfigDict(extra="forbid", title="base_{{assertion.name}}_model")
|
||||
{% for parameter in assertion.parameters %}
|
||||
{% if not parameter.is_deprecated %}
|
||||
{{ parameter.name }}: {{ parameter.type_str }} = Field(
|
||||
{{ parameter.field_default_str }},
|
||||
title="{{ parameter.title }}",
|
||||
description={{ assertion.name }}_{{ parameter.name }}_description,
|
||||
)
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
{% if assertion.children in ["required", "allowed"] %}
|
||||
children: typing.Optional["assertion_list"] = None
|
||||
asserts: typing.Optional["assertion_list"] = None
|
||||
children: typing.Optional["assertion_list"] = Field(None, title="Children")
|
||||
asserts: typing.Optional["assertion_list"] = Field(None, title="Asserts")
|
||||
|
||||
{% if assertion.children == "required" %}
|
||||
@model_validator(mode='before')
|
||||
@@ -139,17 +141,19 @@ class base_{{assertion.name}}_model(AssertionModel):
|
||||
|
||||
class base_{{assertion.name}}_model_relaxed(AssertionModel):
|
||||
'''base model for {{assertion.name}} describing attributes.'''
|
||||
model_config = ConfigDict(extra="forbid", title="base_{{assertion.name}}_model_relaxed")
|
||||
{% for parameter in assertion.parameters %}
|
||||
{% if not parameter.is_deprecated %}
|
||||
{{ parameter.name }}: {{ parameter.lax_type_str }} = Field(
|
||||
{{ parameter.field_default_str }},
|
||||
title="{{ parameter.title }}",
|
||||
description={{ assertion.name }}_{{ parameter.name }}_description,
|
||||
)
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
{% if assertion.children in ["required", "allowed"] %}
|
||||
children: typing.Optional["assertion_list"] = None
|
||||
asserts: typing.Optional["assertion_list"] = None
|
||||
children: typing.Optional["assertion_list"] = Field(None, title="Children")
|
||||
asserts: typing.Optional["assertion_list"] = Field(None, title="Asserts")
|
||||
|
||||
{% if assertion.children == "required" %}
|
||||
@model_validator(mode='before')
|
||||
@@ -164,15 +168,18 @@ class base_{{assertion.name}}_model_relaxed(AssertionModel):
|
||||
|
||||
class {{assertion.name}}_model(base_{{assertion.name}}_model):
|
||||
r\"\"\"{{ assertion.docstring }}\"\"\"
|
||||
that: Literal["{{assertion.name}}"] = "{{assertion.name}}"
|
||||
model_config = ConfigDict(extra="forbid", title="{{ assertion.title }}")
|
||||
that: Literal["{{assertion.name}}"] = Field("{{assertion.name}}", title="That")
|
||||
|
||||
class {{assertion.name}}_model_nested(AssertionModel):
|
||||
r\"\"\"Nested version of this assertion model.\"\"\"
|
||||
{{assertion.name}}: base_{{assertion.name}}_model
|
||||
model_config = ConfigDict(extra="forbid", title="{{ assertion.title }} (Nested)")
|
||||
{{assertion.name}}: base_{{assertion.name}}_model = Field(..., title="{{ assertion.title }}")
|
||||
|
||||
class {{assertion.name}}_model_relaxed(base_{{assertion.name}}_model_relaxed):
|
||||
r\"\"\"{{ assertion.docstring }}\"\"\"
|
||||
that: Literal["{{assertion.name}}"] = "{{assertion.name}}"
|
||||
model_config = ConfigDict(extra="forbid", title="{{ assertion.title }} (Relaxed)")
|
||||
that: Literal["{{assertion.name}}"] = Field("{{assertion.name}}", title="That")
|
||||
{% endfor %}
|
||||
|
||||
any_assertion_model_flat = Annotated[typing.Union[
|
||||
@@ -193,15 +200,19 @@ any_assertion_model_flat_relaxed = Annotated[typing.Union[
|
||||
{% endfor %}
|
||||
], Field(discriminator="that")]
|
||||
|
||||
assertion_list = RootModel[typing.List[typing.Union[any_assertion_model_flat, any_assertion_model_nested]]]
|
||||
class assertion_list(RootModel[typing.List[typing.Union[any_assertion_model_flat, any_assertion_model_nested]]]):
|
||||
model_config = ConfigDict(title="assertion_list")
|
||||
|
||||
|
||||
# used to model what the XML conversion should look like - not meant to be consumed outside of
|
||||
# of Galaxy internals / linting.
|
||||
relaxed_assertion_list = RootModel[typing.List[any_assertion_model_flat_relaxed]]
|
||||
class relaxed_assertion_list(RootModel[typing.List[any_assertion_model_flat_relaxed]]):
|
||||
model_config = ConfigDict(title="relaxed_assertion_list")
|
||||
|
||||
class assertion_dict(AssertionModel):
|
||||
model_config = ConfigDict(extra="forbid", title="assertion_dict")
|
||||
{% for assertion in assertions %}
|
||||
{{assertion.name}}: typing.Optional[base_{{assertion.name}}_model] = None
|
||||
{{assertion.name}}: typing.Optional[base_{{assertion.name}}_model] = Field(None, title="{{ assertion.title }}")
|
||||
{% endfor %}
|
||||
|
||||
|
||||
@@ -338,6 +349,10 @@ class AssertionParameter:
|
||||
self.type = type
|
||||
self.default_value = default_value
|
||||
|
||||
@property
|
||||
def title(self) -> str:
|
||||
return " ".join(w.capitalize() for w in self.name.split("_"))
|
||||
|
||||
@property
|
||||
def description(self) -> str:
|
||||
type = self.type
|
||||
@@ -490,6 +505,10 @@ class Assertion:
|
||||
self.children = children
|
||||
self.module_and_function = module_and_function
|
||||
|
||||
@property
|
||||
def title(self) -> str:
|
||||
return "Assert " + " ".join(w.capitalize() for w in self.name.split("_"))
|
||||
|
||||
|
||||
def arg_parser() -> argparse.ArgumentParser:
|
||||
parser = argparse.ArgumentParser(description=DESCRIPTION)
|
||||
|
||||
@@ -172,33 +172,153 @@ class ParsedTool(ToolSourceBaseModel):
|
||||
|
||||
|
||||
class BaseTestOutputModel(StrictModel):
|
||||
file: Optional[str] = None
|
||||
path: Optional[str] = None
|
||||
location: Optional[AnyUrl] = None
|
||||
ftype: Optional[str] = None
|
||||
sort: Optional[bool] = None
|
||||
compare: Optional[OutputCompareType] = None
|
||||
checksum: Optional[str] = None
|
||||
metadata: Optional[Dict[str, Any]] = None
|
||||
asserts: Optional[assertions] = None
|
||||
delta: Optional[int] = None
|
||||
delta_frac: Optional[float] = None
|
||||
lines_diff: Optional[int] = None
|
||||
decompress: Optional[bool] = None
|
||||
model_config = ConfigDict(extra="forbid", title="BaseTestOutputModel")
|
||||
file: Annotated[
|
||||
Optional[str],
|
||||
Field(
|
||||
title="File",
|
||||
description=(
|
||||
"Name of the output file stored in the target `test-data` directory that will be used to "
|
||||
"compare against the results of executing the tool via the functional test framework."
|
||||
),
|
||||
),
|
||||
] = None
|
||||
path: Annotated[
|
||||
Optional[str],
|
||||
Field(title="Path", description="Filesystem path to a local output file used for comparison."),
|
||||
] = None
|
||||
location: Annotated[
|
||||
Optional[AnyUrl],
|
||||
Field(
|
||||
title="Location",
|
||||
description=(
|
||||
"URL that points to a remote output file that will be downloaded and used for output "
|
||||
"comparison. Use only when the file cannot be included in the `test-data` folder. May be "
|
||||
"combined with `file` (downloads when missing on disk) or used alone (filename inferred "
|
||||
"from the URL). A `checksum` is also used to verify the download when provided."
|
||||
),
|
||||
),
|
||||
] = None
|
||||
ftype: Annotated[
|
||||
Optional[str],
|
||||
Field(
|
||||
title="File Type",
|
||||
description=(
|
||||
"If specified, this value is checked against the corresponding output's data type. "
|
||||
"If these do not match, the test will fail."
|
||||
),
|
||||
),
|
||||
] = None
|
||||
sort: Annotated[
|
||||
Optional[bool],
|
||||
Field(
|
||||
title="Sort",
|
||||
description=(
|
||||
"Applies only if `compare` is `diff`, `re_match` or `re_match_multiline`. Sorts the lines "
|
||||
"of the history data set before comparison; for `diff` and `re_match` the local file is "
|
||||
"also sorted. Useful for non-deterministic output."
|
||||
),
|
||||
),
|
||||
] = None
|
||||
compare: Annotated[
|
||||
Optional[OutputCompareType],
|
||||
Field(
|
||||
title="Compare",
|
||||
description="Comparison mode used when matching the output against the reference file.",
|
||||
),
|
||||
] = None
|
||||
checksum: Annotated[
|
||||
Optional[str],
|
||||
Field(
|
||||
title="Checksum",
|
||||
description=(
|
||||
"The target output's checksum should match the value specified here, in the form "
|
||||
"`hash_type$hash_value` (e.g. `sha1$8156d7ca0f46ed7abac98f82e36cfaddb2aca041`). Useful "
|
||||
"for large static files where uploading the whole file is inconvenient."
|
||||
),
|
||||
),
|
||||
] = None
|
||||
metadata: Annotated[
|
||||
Optional[Dict[str, Any]],
|
||||
Field(
|
||||
title="Metadata",
|
||||
description="Mapping of metadata keys to expected values for this output.",
|
||||
),
|
||||
] = None
|
||||
asserts: Annotated[
|
||||
Optional[assertions],
|
||||
Field(title="Asserts", description="Assertions about the content of the output."),
|
||||
] = None
|
||||
delta: Annotated[
|
||||
Optional[int],
|
||||
Field(
|
||||
title="Delta",
|
||||
description=(
|
||||
"If `compare` is set to `sim_size`, the maximum allowed absolute size difference (in "
|
||||
"bytes) between the generated data set and the reference file in `test-data/`. Default "
|
||||
"is 10000 bytes. Can be combined with `delta_frac`."
|
||||
),
|
||||
),
|
||||
] = None
|
||||
delta_frac: Annotated[
|
||||
Optional[float],
|
||||
Field(
|
||||
title="Delta Frac",
|
||||
description=(
|
||||
"If `compare` is set to `sim_size`, the maximum allowed relative size difference between "
|
||||
"the generated data set and the reference file in `test-data/`. 0.1 means the generated "
|
||||
"file can differ by at most 10%. Default is not to check for relative size difference. "
|
||||
"Can be combined with `delta`."
|
||||
),
|
||||
),
|
||||
] = None
|
||||
lines_diff: Annotated[
|
||||
Optional[int],
|
||||
Field(
|
||||
title="Lines Diff",
|
||||
description=(
|
||||
"Applies when `compare` is set to `diff`, `re_match`, or `contains`. For `diff`, the "
|
||||
"number of lines of difference to allow (a modified line counts as two: one added, one "
|
||||
"removed)."
|
||||
),
|
||||
),
|
||||
] = None
|
||||
decompress: Annotated[
|
||||
Optional[bool],
|
||||
Field(
|
||||
title="Decompress",
|
||||
description=(
|
||||
"If true, decompress files before comparison. Applies to assertions expressed with "
|
||||
"`assert_contents` or `compare` set to anything but `sim_size`. Useful for testing "
|
||||
"compressed outputs that are non-deterministic despite having deterministic decompressed "
|
||||
"contents. By default, only files compressed with bz2, gzip and zip are automatically "
|
||||
"decompressed."
|
||||
),
|
||||
),
|
||||
] = None
|
||||
|
||||
|
||||
class TestDataOutputAssertions(BaseTestOutputModel):
|
||||
class_: Optional[Literal["File"]] = Field("File", alias="class")
|
||||
model_config = ConfigDict(extra="forbid", title="TestDataOutputAssertions")
|
||||
class_: Optional[Literal["File"]] = Field("File", alias="class", title="Class")
|
||||
|
||||
|
||||
class TestCollectionCollectionElementAssertions(StrictModel):
|
||||
class_: Optional[Literal["Collection"]] = Field("Collection", alias="class")
|
||||
elements: Optional[Dict[str, "TestCollectionElementAssertion"]] = None
|
||||
element_tests: Optional[Dict[str, "TestCollectionElementAssertion"]] = None
|
||||
model_config = ConfigDict(extra="forbid", title="TestCollectionCollectionElementAssertions")
|
||||
class_: Optional[Literal["Collection"]] = Field("Collection", alias="class", title="Class")
|
||||
elements: Annotated[
|
||||
Optional[Dict[str, "TestCollectionElementAssertion"]],
|
||||
Field(title="Elements"),
|
||||
] = None
|
||||
element_tests: Annotated[
|
||||
Optional[Dict[str, "TestCollectionElementAssertion"]],
|
||||
Field(title="Element Tests"),
|
||||
] = None
|
||||
|
||||
|
||||
class TestCollectionDatasetElementAssertions(BaseTestOutputModel):
|
||||
class_: Optional[Literal["File"]] = Field("File", alias="class")
|
||||
model_config = ConfigDict(extra="forbid", title="TestCollectionDatasetElementAssertions")
|
||||
class_: Optional[Literal["File"]] = Field("File", alias="class", title="Class")
|
||||
|
||||
|
||||
def _discriminate_collection_element(v):
|
||||
@@ -224,16 +344,24 @@ TestCollectionCollectionElementAssertions.model_rebuild()
|
||||
|
||||
|
||||
class CollectionAttributes(StrictModel):
|
||||
collection_type: CollectionType = None
|
||||
model_config = ConfigDict(extra="forbid", title="CollectionAttributes")
|
||||
collection_type: Annotated[CollectionType, Field(title="Collection Type")] = None
|
||||
|
||||
|
||||
class TestCollectionOutputAssertions(StrictModel):
|
||||
class_: Optional[Literal["Collection"]] = Field("Collection", alias="class")
|
||||
elements: Optional[Dict[str, TestCollectionElementAssertion]] = None
|
||||
element_tests: Optional[Dict[str, "TestCollectionElementAssertion"]] = None
|
||||
element_count: Optional[int] = None
|
||||
attributes: Optional[CollectionAttributes] = None
|
||||
collection_type: CollectionType = None
|
||||
model_config = ConfigDict(extra="forbid", title="TestCollectionOutputAssertions")
|
||||
class_: Optional[Literal["Collection"]] = Field("Collection", alias="class", title="Class")
|
||||
elements: Annotated[
|
||||
Optional[Dict[str, TestCollectionElementAssertion]],
|
||||
Field(title="Elements"),
|
||||
] = None
|
||||
element_tests: Annotated[
|
||||
Optional[Dict[str, "TestCollectionElementAssertion"]],
|
||||
Field(title="Element Tests"),
|
||||
] = None
|
||||
element_count: Annotated[Optional[int], Field(title="Element Count")] = None
|
||||
attributes: Annotated[Optional[CollectionAttributes], Field(title="Attributes")] = None
|
||||
collection_type: Annotated[CollectionType, Field(title="Collection Type")] = None
|
||||
|
||||
|
||||
TestOutputLiteral = Union[bool, int, float, str]
|
||||
@@ -267,23 +395,25 @@ TestInputValue = Union[bool, int, float, str, List[Any], Dict[str, Any]]
|
||||
|
||||
|
||||
class YamlTestCredentialValue(StrictModel):
|
||||
name: Annotated[str, Field(description="Name of the credential variable or secret.")]
|
||||
value: Annotated[str, Field(description="Value of the credential variable or secret.")]
|
||||
model_config = ConfigDict(extra="forbid", title="YamlTestCredentialValue")
|
||||
name: Annotated[str, Field(title="Name", description="Name of the credential variable or secret.")]
|
||||
value: Annotated[str, Field(title="Value", description="Value of the credential variable or secret.")]
|
||||
|
||||
|
||||
class YamlTestCredential(StrictModel):
|
||||
name: Annotated[str, Field(description="Name of the credentials group.")]
|
||||
model_config = ConfigDict(extra="forbid", title="YamlTestCredential")
|
||||
name: Annotated[str, Field(title="Name", description="Name of the credentials group.")]
|
||||
variables: Annotated[
|
||||
List[YamlTestCredentialValue],
|
||||
Field(description="Variables exposed to the tool environment."),
|
||||
Field(title="Variables", description="Variables exposed to the tool environment."),
|
||||
] = []
|
||||
secrets: Annotated[
|
||||
List[YamlTestCredentialValue],
|
||||
Field(description="Secrets exposed to the tool environment."),
|
||||
Field(title="Secrets", description="Secrets exposed to the tool environment."),
|
||||
] = []
|
||||
version: Annotated[
|
||||
Optional[str],
|
||||
Field(description="Version of the credential definition."),
|
||||
Field(title="Version", description="Version of the credential definition."),
|
||||
] = None
|
||||
|
||||
|
||||
@@ -341,13 +471,52 @@ JobDict = Dict[str, Any]
|
||||
|
||||
|
||||
class TestJob(StrictModel):
|
||||
doc: Optional[str]
|
||||
job: Job
|
||||
outputs: Dict[str, TestOutputAssertions]
|
||||
expect_failure: Optional[bool] = False
|
||||
model_config = ConfigDict(extra="forbid", title="TestJob")
|
||||
doc: Annotated[
|
||||
Optional[str],
|
||||
Field(title="Doc", description="Describes the purpose of the test."),
|
||||
] = None
|
||||
job: Annotated[
|
||||
Job,
|
||||
Field(
|
||||
title="Job",
|
||||
description=(
|
||||
"Defines the job to execute. Can be a path to a file or an inline dictionary describing "
|
||||
"the job inputs."
|
||||
),
|
||||
),
|
||||
]
|
||||
outputs: Annotated[
|
||||
Dict[str, TestOutputAssertions],
|
||||
Field(
|
||||
title="Outputs",
|
||||
description=(
|
||||
"Defines assertions about outputs (datasets, collections or parameters). Each key "
|
||||
"corresponds to a labeled output; values are dictionaries describing the expected output."
|
||||
),
|
||||
),
|
||||
]
|
||||
expect_failure: Annotated[
|
||||
Optional[bool],
|
||||
Field(
|
||||
title="Expect Failure",
|
||||
description="If true, the workflow is expected to produce an error.",
|
||||
),
|
||||
] = False
|
||||
|
||||
|
||||
Tests = RootModel[List[TestJob]]
|
||||
class Tests(RootModel[List[TestJob]]):
|
||||
model_config = ConfigDict(
|
||||
title="GalaxyWorkflowTests",
|
||||
json_schema_extra={
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"description": (
|
||||
"Galaxy workflow tests file — a YAML list of test entries asserting the expected "
|
||||
"inputs and outputs of a workflow run."
|
||||
),
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
# TODO: typed dict versions of all thee above for verify code - make this Dict[str, Any] here more
|
||||
# specific.
|
||||
|
||||
@@ -11,11 +11,11 @@ from typing_extensions import Annotated
|
||||
|
||||
|
||||
class ToolSourceBaseModel(BaseModel):
|
||||
model_config = ConfigDict(field_title_generator=lambda field_name, field_info: field_name.lower())
|
||||
pass
|
||||
|
||||
|
||||
class StrictModel(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid", field_title_generator=lambda field_name, field_info: field_name.lower())
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
|
||||
def _check_collection_type(v: str) -> str:
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -43,60 +43,65 @@ class _StrictJobModel(StrictModel):
|
||||
model_config = ConfigDict(
|
||||
extra="forbid",
|
||||
populate_by_name=True,
|
||||
field_title_generator=lambda field_name, field_info: field_name.lower(),
|
||||
)
|
||||
|
||||
|
||||
class HashEntry(_StrictJobModel):
|
||||
hash_function: HashFunctionNames
|
||||
hash_value: str
|
||||
model_config = ConfigDict(extra="forbid", populate_by_name=True, title="HashEntry")
|
||||
hash_function: Annotated[HashFunctionNames, Field(title="Hash Function")]
|
||||
hash_value: Annotated[str, Field(title="Hash Value")]
|
||||
|
||||
|
||||
class BaseFile(_StrictJobModel):
|
||||
"""Fields common to every ``class: File`` variant."""
|
||||
|
||||
class_: Literal["File"] = Field(alias="class")
|
||||
filetype: Optional[str] = None
|
||||
dbkey: Optional[str] = None
|
||||
decompress: Optional[bool] = None
|
||||
to_posix_lines: Optional[bool] = None
|
||||
space_to_tab: Optional[bool] = None
|
||||
deferred: Optional[bool] = None
|
||||
name: Optional[str] = None
|
||||
info: Optional[str] = None
|
||||
tags: Optional[list[str]] = None
|
||||
hashes: Optional[list[HashEntry]] = None
|
||||
identifier: Optional[str] = None
|
||||
model_config = ConfigDict(extra="forbid", populate_by_name=True, title="BaseFile")
|
||||
class_: Literal["File"] = Field(alias="class", title="Class")
|
||||
filetype: Annotated[Optional[str], Field(title="File Type")] = None
|
||||
dbkey: Annotated[Optional[str], Field(title="Dbkey")] = None
|
||||
decompress: Annotated[Optional[bool], Field(title="Decompress")] = None
|
||||
to_posix_lines: Annotated[Optional[bool], Field(title="To POSIX Lines")] = None
|
||||
space_to_tab: Annotated[Optional[bool], Field(title="Space To Tab")] = None
|
||||
deferred: Annotated[Optional[bool], Field(title="Deferred")] = None
|
||||
name: Annotated[Optional[str], Field(title="Name")] = None
|
||||
info: Annotated[Optional[str], Field(title="Info")] = None
|
||||
tags: Annotated[Optional[list[str]], Field(title="Tags")] = None
|
||||
hashes: Annotated[Optional[list[HashEntry]], Field(title="Hashes")] = None
|
||||
identifier: Annotated[Optional[str], Field(title="Identifier")] = None
|
||||
|
||||
|
||||
class LocationFile(BaseFile):
|
||||
location: str
|
||||
path: Optional[str] = None
|
||||
contents: Optional[str] = None
|
||||
composite_data: Optional[list[str]] = None
|
||||
model_config = ConfigDict(extra="forbid", populate_by_name=True, title="LocationFile")
|
||||
location: Annotated[str, Field(title="Location")]
|
||||
path: Annotated[Optional[str], Field(title="Path")] = None
|
||||
contents: Annotated[Optional[str], Field(title="Contents")] = None
|
||||
composite_data: Annotated[Optional[list[str]], Field(title="Composite Data")] = None
|
||||
|
||||
|
||||
class PathFile(BaseFile):
|
||||
path: str
|
||||
location: Optional[str] = None
|
||||
contents: Optional[str] = None
|
||||
composite_data: Optional[list[str]] = None
|
||||
model_config = ConfigDict(extra="forbid", populate_by_name=True, title="PathFile")
|
||||
path: Annotated[str, Field(title="Path")]
|
||||
location: Annotated[Optional[str], Field(title="Location")] = None
|
||||
contents: Annotated[Optional[str], Field(title="Contents")] = None
|
||||
composite_data: Annotated[Optional[list[str]], Field(title="Composite Data")] = None
|
||||
|
||||
|
||||
class ContentsFile(BaseFile):
|
||||
"""CWL File literal — content inlined as a string."""
|
||||
|
||||
contents: str
|
||||
path: Optional[str] = None
|
||||
location: Optional[str] = None
|
||||
composite_data: Optional[list[str]] = None
|
||||
model_config = ConfigDict(extra="forbid", populate_by_name=True, title="ContentsFile")
|
||||
contents: Annotated[str, Field(title="Contents")]
|
||||
path: Annotated[Optional[str], Field(title="Path")] = None
|
||||
location: Annotated[Optional[str], Field(title="Location")] = None
|
||||
composite_data: Annotated[Optional[list[str]], Field(title="Composite Data")] = None
|
||||
|
||||
|
||||
class CompositeDataFile(BaseFile):
|
||||
composite_data: list[str]
|
||||
path: Optional[str] = None
|
||||
location: Optional[str] = None
|
||||
contents: Optional[str] = None
|
||||
model_config = ConfigDict(extra="forbid", populate_by_name=True, title="CompositeDataFile")
|
||||
composite_data: Annotated[list[str], Field(title="Composite Data")]
|
||||
path: Annotated[Optional[str], Field(title="Path")] = None
|
||||
location: Annotated[Optional[str], Field(title="Location")] = None
|
||||
contents: Annotated[Optional[str], Field(title="Contents")] = None
|
||||
|
||||
|
||||
def _discriminate_file(v):
|
||||
@@ -133,12 +138,13 @@ File = Annotated[
|
||||
|
||||
|
||||
class Collection(_StrictJobModel):
|
||||
class_: Literal["Collection"] = Field(alias="class")
|
||||
collection_type: CollectionType = None
|
||||
name: Optional[str] = None
|
||||
identifier: Optional[str] = None
|
||||
elements: Optional[list["CollectionElement"]] = None
|
||||
rows: Optional[dict[str, list]] = None
|
||||
model_config = ConfigDict(extra="forbid", populate_by_name=True, title="Collection")
|
||||
class_: Literal["Collection"] = Field(alias="class", title="Class")
|
||||
collection_type: Annotated[CollectionType, Field(title="Collection Type")] = None
|
||||
name: Annotated[Optional[str], Field(title="Name")] = None
|
||||
identifier: Annotated[Optional[str], Field(title="Identifier")] = None
|
||||
elements: Annotated[Optional[list["CollectionElement"]], Field(title="Elements")] = None
|
||||
rows: Annotated[Optional[dict[str, list]], Field(title="Rows")] = None
|
||||
|
||||
|
||||
CollectionElement = Annotated[
|
||||
@@ -155,11 +161,12 @@ class Directory(_StrictJobModel):
|
||||
does not use it.
|
||||
"""
|
||||
|
||||
class_: Literal["Directory"] = Field(alias="class")
|
||||
path: Optional[str] = None
|
||||
location: Optional[str] = None
|
||||
filetype: Optional[str] = None
|
||||
name: Optional[str] = None
|
||||
model_config = ConfigDict(extra="forbid", populate_by_name=True, title="Directory")
|
||||
class_: Literal["Directory"] = Field(alias="class", title="Class")
|
||||
path: Annotated[Optional[str], Field(title="Path")] = None
|
||||
location: Annotated[Optional[str], Field(title="Location")] = None
|
||||
filetype: Annotated[Optional[str], Field(title="File Type")] = None
|
||||
name: Annotated[Optional[str], Field(title="Name")] = None
|
||||
|
||||
|
||||
# JobParamValue is non-recursive at the list axis: a job-param list may contain
|
||||
@@ -179,4 +186,6 @@ JobParamValue = Union[
|
||||
list[Union[File, str, int, float, bool, None]],
|
||||
]
|
||||
|
||||
Job = RootModel[dict[str, JobParamValue]]
|
||||
|
||||
class Job(RootModel[dict[str, JobParamValue]]):
|
||||
model_config = ConfigDict(title="Job")
|
||||
|
||||
@@ -93,3 +93,81 @@ def test_json_schema_emits_discriminator_for_outputs():
|
||||
schema = Tests.model_json_schema()
|
||||
dumped = json.dumps(schema)
|
||||
assert "discriminator" in dumped or '"oneOf"' in dumped
|
||||
|
||||
|
||||
def test_doc_is_optional_on_test_job():
|
||||
tests = Tests.model_validate([{"job": {}, "outputs": {}}])
|
||||
assert tests.root[0].doc is None
|
||||
schema = Tests.model_json_schema()
|
||||
test_job = schema["$defs"]["TestJob"]
|
||||
assert "doc" not in test_job.get("required", [])
|
||||
|
||||
|
||||
def test_test_job_top_level_properties_have_descriptions():
|
||||
schema = Tests.model_json_schema()
|
||||
props = schema["$defs"]["TestJob"]["properties"]
|
||||
for name in ("doc", "job", "outputs", "expect_failure"):
|
||||
assert props[name].get("description"), f"missing description on TestJob.{name}"
|
||||
|
||||
|
||||
def test_test_job_titles_are_human_readable_not_lowercase():
|
||||
schema = Tests.model_json_schema()
|
||||
props = schema["$defs"]["TestJob"]["properties"]
|
||||
assert props["doc"]["title"] == "Doc"
|
||||
assert props["outputs"]["title"] == "Outputs"
|
||||
assert props["expect_failure"]["title"] == "Expect Failure"
|
||||
|
||||
|
||||
def test_assertion_model_titles_are_human_readable():
|
||||
schema = Tests.model_json_schema()
|
||||
defs = schema["$defs"]
|
||||
assert defs["has_text_model"]["title"] == "Assert Has Text"
|
||||
assert defs["has_text_model_nested"]["title"] == "Assert Has Text (Nested)"
|
||||
# Every field in has_text_model has an explicit title.
|
||||
for name, prop in defs["has_text_model"]["properties"].items():
|
||||
assert prop.get("title"), f"has_text_model.{name} missing title"
|
||||
|
||||
|
||||
def test_test_data_output_assertions_field_titles():
|
||||
schema = Tests.model_json_schema()
|
||||
props = schema["$defs"]["TestDataOutputAssertions"]["properties"]
|
||||
assert props["lines_diff"]["title"] == "Lines Diff"
|
||||
assert props["class"]["title"] == "Class"
|
||||
assert props["ftype"]["title"] == "File Type"
|
||||
|
||||
|
||||
def test_top_level_schema_has_title_description_and_schema():
|
||||
schema = Tests.model_json_schema()
|
||||
assert schema.get("title") == "GalaxyWorkflowTests"
|
||||
assert schema.get("description")
|
||||
assert schema.get("$schema") == "https://json-schema.org/draft/2020-12/schema"
|
||||
|
||||
|
||||
def test_root_model_wrappers_have_clean_defs_keys():
|
||||
schema = Tests.model_json_schema()
|
||||
defs = schema["$defs"]
|
||||
assert "Job" in defs
|
||||
assert "assertion_list" in defs
|
||||
# None of the RootModel-auto-generated mangled names should appear.
|
||||
mangled = [k for k in defs if k.startswith("RootModel_")]
|
||||
assert not mangled, f"unexpected mangled RootModel defs: {mangled}"
|
||||
|
||||
|
||||
def test_test_data_output_assertions_properties_have_descriptions():
|
||||
schema = Tests.model_json_schema()
|
||||
props = schema["$defs"]["TestDataOutputAssertions"]["properties"]
|
||||
for name in (
|
||||
"asserts",
|
||||
"metadata",
|
||||
"file",
|
||||
"ftype",
|
||||
"sort",
|
||||
"checksum",
|
||||
"compare",
|
||||
"lines_diff",
|
||||
"decompress",
|
||||
"delta",
|
||||
"delta_frac",
|
||||
"location",
|
||||
):
|
||||
assert props[name].get("description"), f"missing description on TestDataOutputAssertions.{name}"
|
||||
|
||||
Reference in New Issue
Block a user