From ff3027277cf618429cc47725f6281e791c1d6173 Mon Sep 17 00:00:00 2001 From: "Ware, Joseph (DLSLtd,RAL,LSCI)" <53935796+DiamondJoseph@users.noreply.github.com> Date: Mon, 14 Jul 2025 14:03:15 +0100 Subject: [PATCH 1/4] chore(deps): Update ScanSpec and unpin dependencies --- dev-requirements.txt | 4 +++- pyproject.toml | 4 ++-- tests/system_tests/plans.json | 8 ++++---- 3 files changed, 9 insertions(+), 7 deletions(-) diff --git a/dev-requirements.txt b/dev-requirements.txt index cec6c48cd9..c447498956 100644 --- a/dev-requirements.txt +++ b/dev-requirements.txt @@ -2,6 +2,7 @@ accessible-pygments==0.0.5 aioca==1.8.1 aiofiles==24.1.0 aiohappyeyeballs==2.6.1 +aiohttp==3.12.13 aiosignal==1.3.2 alabaster==1.0.0 annotated-types==0.7.0 @@ -157,8 +158,9 @@ rpds-py==0.24.0 ruamel.yaml==0.18.10 ruamel.yaml.clib==0.2.12 ruff==0.11.7 -scanspec==0.7.8 +scanspec==0.8.0 semver==3.0.4 +setuptools==80.9.0 setuptools_dso==2.12.2 shellingham==1.5.4 six==1.17.0 diff --git a/pyproject.toml b/pyproject.toml index 187c881473..5b3f482c3d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -15,8 +15,8 @@ dependencies = [ "bluesky[plotting]>=1.13.1", # plotting includes matplotlib, required for BestEffortCallback in run plans "ophyd-async", "aioca", - "pydantic(>=2.0,<=2.10.6)", # https://github.com/DiamondLightSource/blueapi/issues/908 - "scanspec>=0.7.8", + "pydantic>=2.0", + "scanspec>=0.8.0", "pydantic-settings", "stomp-py", "PyYAML>=6.0.2", diff --git a/tests/system_tests/plans.json b/tests/system_tests/plans.json index 46d0064d34..3fa9d69226 100644 --- a/tests/system_tests/plans.json +++ b/tests/system_tests/plans.json @@ -331,7 +331,7 @@ }, "Mask_Reference_": { "additionalProperties": false, - "description": "Restrict Spec to only midpoints that fall inside the given Region.\n\nTypically created with the ``&`` operator. It also pushes down the\n``& | ^ -`` operators to its `Region` to avoid the need for brackets on\ncombinations of Regions.\n\nIf a Region spans multiple Frames objects, they will be squashed together.\n\n.. example_spec::\n\n from scanspec.regions import Circle\n from scanspec.specs import Line\n\n spec = Line(\"y\", 1, 3, 3) * Line(\"x\", 3, 5, 5) & Circle(\"x\", \"y\", 4, 2, 1.2)\n\nSee Also: `why-squash-can-change-path`", + "description": "Restrict Spec to only midpoints that fall inside the given Region.\n\nTypically created with the ``&`` operator. It also pushes down the\n``& | ^ -`` operators to its `Region` to avoid the need for brackets on\ncombinations of Regions.\n\nIf a Region spans multiple Dimension objects, they will be squashed together.\n\n.. example_spec::\n\n from scanspec.regions import Circle\n from scanspec.specs import Line\n\n spec = Line(\"y\", 1, 3, 3) * Line(\"x\", 3, 5, 5) & Circle(\"x\", \"y\", 4, 2, 1.2)\n\nSee Also: `why-squash-can-change-path`", "properties": { "spec": { "$ref": "#/$defs/Spec", @@ -593,7 +593,7 @@ }, "gap": { "default": true, - "description": "If False and the slowest of the stack of Frames is snaked then the end and start of consecutive iterations of Spec will have no gap", + "description": "If False and the slowest of the stack of Dimension is snaked then the end and start of consecutive iterations of Spec will have no gap", "title": "Gap", "type": "boolean" }, @@ -749,7 +749,7 @@ }, "Squash_Reference_": { "additionalProperties": false, - "description": "Squash a stack of Frames together into a single expanded Frames object.\n\nSee Also:\n `why-squash-can-change-path`\n\n.. example_spec::\n\n from scanspec.specs import Line, Squash\n\n spec = Squash(Line(\"y\", 1, 2, 3) * Line(\"x\", 0, 1, 4))", + "description": "Squash a stack of Dimension together into a single expanded Dimension object.\n\nSee Also:\n `why-squash-can-change-path`\n\n.. example_spec::\n\n from scanspec.specs import Line, Squash\n\n spec = Squash(Line(\"y\", 1, 2, 3) * Line(\"x\", 0, 1, 4))", "properties": { "spec": { "$ref": "#/$defs/Spec", @@ -864,7 +864,7 @@ }, "Zip_Reference_": { "additionalProperties": false, - "description": "Run two Specs in parallel, merging their midpoints together.\n\nTypically formed using `Spec.zip`.\n\nStacks of Frames are merged by:\n\n- If right creates a stack of a single Frames object of size 1, expand it to\n the size of the fastest Frames object created by left\n- Merge individual Frames objects together from fastest to slowest\n\nThis means that Zipping a Spec producing stack [l2, l1] with a Spec\nproducing stack [r1] will assert len(l1)==len(r1), and produce\nstack [l2, l1.zip(r1)].\n\n.. example_spec::\n\n from scanspec.specs import Line\n\n spec = Line(\"z\", 1, 2, 3) * Line(\"y\", 3, 4, 5).zip(Line(\"x\", 4, 5, 5))", + "description": "Run two Specs in parallel, merging their midpoints together.\n\nTypically formed using `Spec.zip`.\n\nStacks of Dimension are merged by:\n\n- If right creates a stack of a single Dimension object of size 1, expand it to\n the size of the fastest Dimension object created by left\n- Merge individual Dimension objects together from fastest to slowest\n\nThis means that Zipping a Spec producing stack [l2, l1] with a Spec\nproducing stack [r1] will assert len(l1)==len(r1), and produce\nstack [l2, l1.zip(r1)].\n\n.. example_spec::\n\n from scanspec.specs import Line\n\n spec = Line(\"z\", 1, 2, 3) * Line(\"y\", 3, 4, 5).zip(Line(\"x\", 4, 5, 5))", "properties": { "left": { "$ref": "#/$defs/Spec", From d1b16018d2dc42fb327223420ca88e6e9f4194b6 Mon Sep 17 00:00:00 2001 From: "Ware, Joseph (DLSLtd,RAL,LSCI)" <53935796+DiamondJoseph@users.noreply.github.com> Date: Mon, 14 Jul 2025 14:09:08 +0100 Subject: [PATCH 2/4] chore(deps): Update dodal to 1.51.0 --- dev-requirements.txt | 5 +++-- pyproject.toml | 2 +- tests/system_tests/devices.json | 2 +- tests/system_tests/plans.json | 4 ++-- tests/system_tests/test_blueapi_system.py | 5 +++-- 5 files changed, 10 insertions(+), 8 deletions(-) diff --git a/dev-requirements.txt b/dev-requirements.txt index c447498956..e9b65bf165 100644 --- a/dev-requirements.txt +++ b/dev-requirements.txt @@ -32,7 +32,7 @@ deepdiff==8.4.2 deepmerge==2.0 Deprecated==1.2.18 distlib==0.3.9 -dls-dodal==1.46.0 +dls-dodal==1.51.0 dnspython==2.7.0 docopt==0.6.2 docutils==0.21.2 @@ -105,7 +105,7 @@ opentelemetry-sdk==1.32.1 opentelemetry-semantic-conventions==0.53b1 opentelemetry-util-http==0.53b1 ophyd==1.10.6 -ophyd-async==0.10.0a3 +ophyd-async==0.11 orderly-set==5.4.1 orjson==3.10.18 p4p==4.2.0 @@ -146,6 +146,7 @@ python-dateutil==2.9.0.post0 python-dotenv==1.1.0 python-multipart==0.0.20 PyYAML==6.0.2 +pyzmq==26.3.0 questionary==2.1.0 redis==6.2.0 referencing==0.36.2 diff --git a/pyproject.toml b/pyproject.toml index 5b3f482c3d..b8f06c2388 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -24,7 +24,7 @@ dependencies = [ "fastapi>=0.112.0", "uvicorn", "requests", - "dls-dodal>=1.46.0", + "dls-dodal>=1.51.0", "super-state-machine", # https://github.com/DiamondLightSource/blueapi/issues/553 "GitPython", "event-model==1.23", # https://github.com/DiamondLightSource/blueapi/issues/684 diff --git a/tests/system_tests/devices.json b/tests/system_tests/devices.json index dd794cc86d..e720c12abf 100644 --- a/tests/system_tests/devices.json +++ b/tests/system_tests/devices.json @@ -34,7 +34,7 @@ ] }, { - "name": "sim", + "name": "stage", "protocols": [ { "name": "Readable", diff --git a/tests/system_tests/plans.json b/tests/system_tests/plans.json index 3fa9d69226..73e8843faa 100644 --- a/tests/system_tests/plans.json +++ b/tests/system_tests/plans.json @@ -10,7 +10,7 @@ "items": { "enum": [ "det", - "sim" + "stage" ], "type": "bluesky.protocols.Readable" }, @@ -895,7 +895,7 @@ "items": { "enum": [ "det", - "sim" + "stage" ], "type": "bluesky.protocols.Readable" }, diff --git a/tests/system_tests/test_blueapi_system.py b/tests/system_tests/test_blueapi_system.py index d461491511..bb1fbfa286 100644 --- a/tests/system_tests/test_blueapi_system.py +++ b/tests/system_tests/test_blueapi_system.py @@ -392,14 +392,15 @@ def test_delete_current_environment(client: BlueapiClient): "detectors": [ "det", ], - "spec": Line("sim.x", 0.0, 10.0, 2) * Line("sim.y", 5.0, 15.0, 3), + "spec": Line("stage.x", 0.0, 10.0, 2) + * Line("stage.theta", 5.0, 15.0, 3), }, instrument_session="cm12345-1", ), TaskRequest( name="set_absolute", params={ - "movable": "sim.x", + "movable": "stage.x", "value": "4.0", }, instrument_session="cm12345-1", From 6a7994110e8446f285a06ba2fbe98ec3190279b3 Mon Sep 17 00:00:00 2001 From: "Ware, Joseph (DLSLtd,RAL,LSCI)" <53935796+DiamondJoseph@users.noreply.github.com> Date: Mon, 14 Jul 2025 14:12:05 +0100 Subject: [PATCH 3/4] chore(deps): Update dev-requirements --- dev-requirements.txt | 143 +++++++++++++++++----------------- docs/reference/openapi.yaml | 4 +- src/blueapi/service/main.py | 2 +- tests/system_tests/plans.json | 4 + 4 files changed, 81 insertions(+), 72 deletions(-) diff --git a/dev-requirements.txt b/dev-requirements.txt index e9b65bf165..ec31eca594 100644 --- a/dev-requirements.txt +++ b/dev-requirements.txt @@ -2,19 +2,19 @@ accessible-pygments==0.0.5 aioca==1.8.1 aiofiles==24.1.0 aiohappyeyeballs==2.6.1 -aiohttp==3.12.13 -aiosignal==1.3.2 +aiohttp==3.12.14 +aiosignal==1.4.0 alabaster==1.0.0 annotated-types==0.7.0 anyio==4.9.0 -asgiref==3.8.1 +asgiref==3.9.1 attrs==25.3.0 babel==2.17.0 beautifulsoup4==4.13.4 bidict==0.23.1 -bluesky==1.13.1 -bluesky-stomp==0.1.6 -certifi==2025.1.31 +bluesky==1.14.2 +bluesky-stomp==0.1.7 +certifi==2025.7.14 cffi==1.17.1 cfgv==3.4.0 charset-normalizer==3.4.2 @@ -23,12 +23,12 @@ colorama==0.4.6 colorlog==6.9.0 compress-pickle==2.1.0 contourpy==1.3.2 -copier==9.7.1 -coverage==7.8.2 -cryptography==45.0.3 +copier==9.8.0 +coverage==7.9.2 +cryptography==45.0.5 cycler==0.12.1 dataclasses-json==0.6.7 -deepdiff==8.4.2 +deepdiff==8.5.0 deepmerge==2.0 Deprecated==1.2.18 distlib==0.3.9 @@ -36,23 +36,24 @@ dls-dodal==1.51.0 dnspython==2.7.0 docopt==0.6.2 docutils==0.21.2 -dunamai==1.23.1 +dunamai==1.25.0 email_validator==2.2.0 epicscorelibs==7.0.7.99.1.2a1 event-model==1.23 -fastapi==0.115.12 -fastapi-cli==0.0.7 +fastapi==0.116.1 +fastapi-cli==0.0.8 +fastapi-cloud-cli==0.1.4 filelock==3.18.0 flexcache==0.3 flexparser==0.4 -fonttools==4.57.0 -frozenlist==1.6.0 +fonttools==4.58.5 +frozenlist==1.7.0 funcy==2.0 gitdb==4.0.12 GitPython==3.1.44 googleapis-common-protos==1.70.0 graypy==2.1.0 -grpcio==1.71.0 +grpcio==1.73.1 h11==0.16.0 historydict==1.2.6 httpcore==1.0.9 @@ -61,13 +62,13 @@ httpx==0.28.1 identify==2.6.12 idna==3.10 imagesize==1.4.1 -importlib_metadata==8.6.1 +importlib_metadata==8.7.0 importlib_resources==6.5.2 iniconfig==2.1.0 itsdangerous==2.2.0 Jinja2==3.1.6 jinja2-ansible-filters==1.3.2 -jsonschema==4.23.0 +jsonschema==4.24.0 jsonschema-specifications==2025.4.1 jwcrypto==1.5.6 kiwisolver==1.4.8 @@ -80,33 +81,33 @@ mdit-py-plugins==0.4.2 mdurl==0.1.2 mistune==3.1.3 mock==5.2.0 -msgpack==1.1.0 +msgpack==1.1.1 msgpack-numpy==0.4.8 -multidict==6.4.4 +multidict==6.6.3 mypy_extensions==1.1.0 myst-parser==4.0.1 -networkx==3.4.2 +networkx==3.5 nodeenv==1.9.1 nose2==0.15.1 numpy==2.2.6 -observability-utils==0.1.4 -opencv-python-headless==4.11.0.86 -opentelemetry-api==1.32.1 -opentelemetry-distro==0.53b1 -opentelemetry-exporter-otlp==1.32.1 -opentelemetry-exporter-otlp-proto-common==1.32.1 -opentelemetry-exporter-otlp-proto-grpc==1.32.1 -opentelemetry-exporter-otlp-proto-http==1.32.1 -opentelemetry-instrumentation==0.53b1 -opentelemetry-instrumentation-asgi==0.53b1 -opentelemetry-instrumentation-fastapi==0.53b1 -opentelemetry-proto==1.32.1 -opentelemetry-sdk==1.32.1 -opentelemetry-semantic-conventions==0.53b1 -opentelemetry-util-http==0.53b1 -ophyd==1.10.6 +observability-utils==0.1.5 +opencv-python-headless==4.12.0.88 +opentelemetry-api==1.35.0 +opentelemetry-distro==0.56b0 +opentelemetry-exporter-otlp==1.35.0 +opentelemetry-exporter-otlp-proto-common==1.35.0 +opentelemetry-exporter-otlp-proto-grpc==1.35.0 +opentelemetry-exporter-otlp-proto-http==1.35.0 +opentelemetry-instrumentation==0.56b0 +opentelemetry-instrumentation-asgi==0.56b0 +opentelemetry-instrumentation-fastapi==0.56b0 +opentelemetry-proto==1.35.0 +opentelemetry-sdk==1.35.0 +opentelemetry-semantic-conventions==0.56b0 +opentelemetry-util-http==0.56b0 +ophyd==1.10.7 ophyd-async==0.11 -orderly-set==5.4.1 +orderly-set==5.5.0 orjson==3.10.18 p4p==4.2.0 packaging==25.0 @@ -116,34 +117,34 @@ picobox==4.0.0 pika==1.3.2 pillow==11.3.0 Pint==0.24.4 -pipdeptree==2.26.1 +pipdeptree==2.27.0 platformdirs==4.3.8 -pluggy==1.5.0 +pluggy==1.6.0 plumbum==1.9.0 ply==3.11 pre_commit==4.2.0 prompt_toolkit==3.0.51 -propcache==0.3.1 -protobuf==5.29.5 +propcache==0.3.2 +protobuf==6.31.1 pvxslibs==1.3.3 py==1.11.0 pycparser==2.22 -pydantic==2.10.6 -pydantic-extra-types==2.10.4 -pydantic-settings==2.9.1 -pydantic_core==2.27.2 +pydantic==2.11.7 +pydantic-extra-types==2.10.5 +pydantic-settings==2.10.1 +pydantic_core==2.33.2 pydantic_numpy==8.0.1 pydata-sphinx-theme==0.16.1 -pyepics==3.5.7 -Pygments==2.19.1 +pyepics==3.5.8 +Pygments==2.19.2 PyJWT==2.10.1 pyparsing==3.2.3 -pyright==1.1.401 -pytest==8.3.5 +pyright==1.1.403 +pytest==8.4.1 pytest-asyncio==1.0.0 -pytest-cov==6.1.1 +pytest-cov==6.2.1 python-dateutil==2.9.0.post0 -python-dotenv==1.1.0 +python-dotenv==1.1.1 python-multipart==0.0.20 PyYAML==6.0.2 pyzmq==26.3.0 @@ -153,14 +154,16 @@ referencing==0.36.2 requests==2.32.4 responses==0.25.7 rich==14.0.0 -rich-toolkit==0.14.3 +rich-toolkit==0.14.8 +rignore==0.6.2 roman-numerals-py==3.1.0 -rpds-py==0.24.0 -ruamel.yaml==0.18.10 +rpds-py==0.26.0 +ruamel.yaml==0.18.14 ruamel.yaml.clib==0.2.12 -ruff==0.11.7 +ruff==0.12.3 scanspec==0.8.0 semver==3.0.4 +sentry-sdk==2.32.0 setuptools==80.9.0 setuptools_dso==2.12.2 shellingham==1.5.4 @@ -183,33 +186,33 @@ sphinxcontrib-jsmath==1.0.1 sphinxcontrib-openapi==0.8.4 sphinxcontrib-qthelp==2.0.0 sphinxcontrib-serializinghtml==2.0.0 -starlette==0.46.2 +starlette==0.47.1 stomp.py==8.2.0 super-state-machine==2.0.2 -tomlkit==0.13.2 +tomlkit==0.13.3 toolz==1.0.0 tox==3.28.0 tox-direct==0.4 tqdm==4.67.1 -typer==0.15.2 -types-mock==5.2.0.20250306 -types-PyYAML==6.0.12.20250402 -types-requests==2.32.0.20250328 +typer==0.16.0 +types-mock==5.2.0.20250516 +types-PyYAML==6.0.12.20250516 +types-requests==2.32.4.20250611 types-urllib3==1.26.25.14 typing-inspect==0.9.0 -typing-inspection==0.4.0 -typing_extensions==4.13.2 +typing-inspection==0.4.1 +typing_extensions==4.14.1 ujson==5.10.0 urllib3==2.5.0 -uvicorn==0.34.2 +uvicorn==0.35.0 uvloop==0.21.0 -virtualenv==20.30.0 -watchfiles==1.0.5 +virtualenv==20.31.2 +watchfiles==1.1.0 wcwidth==0.2.13 websocket-client==1.8.0 websockets==15.0.1 workflows==3.2 wrapt==1.17.2 -yarl==1.20.0 -zipp==3.21.0 -zocalo==1.2.0 +yarl==1.20.1 +zipp==3.23.0 +zocalo==1.3.0 diff --git a/docs/reference/openapi.yaml b/docs/reference/openapi.yaml index 5ced485eb8..ec081d0b39 100644 --- a/docs/reference/openapi.yaml +++ b/docs/reference/openapi.yaml @@ -139,6 +139,7 @@ components: title: Name type: string schema: + additionalProperties: true description: Schema of the plan's parameters title: Schema type: object @@ -231,6 +232,7 @@ components: title: Name type: string params: + additionalProperties: true description: Values for parameters to plan, if any title: Params type: object @@ -348,7 +350,7 @@ info: name: Apache 2.0 url: https://www.apache.org/licenses/LICENSE-2.0.html title: BlueAPI Control - version: 1.0.1 + version: 1.0.2 openapi: 3.1.0 paths: /config/oidc: diff --git a/src/blueapi/service/main.py b/src/blueapi/service/main.py index 6a5e3c30f1..56f129a035 100644 --- a/src/blueapi/service/main.py +++ b/src/blueapi/service/main.py @@ -56,7 +56,7 @@ from .runner import WorkerDispatcher #: API version to publish in OpenAPI schema -REST_API_VERSION = "1.0.1" +REST_API_VERSION = "1.0.2" LICENSE_INFO: dict[str, str] = { "name": "Apache 2.0", diff --git a/tests/system_tests/plans.json b/tests/system_tests/plans.json index 73e8843faa..bd19637753 100644 --- a/tests/system_tests/plans.json +++ b/tests/system_tests/plans.json @@ -39,6 +39,7 @@ "metadata": { "anyOf": [ { + "additionalProperties": true, "type": "object" }, { @@ -909,6 +910,7 @@ "metadata": { "anyOf": [ { + "additionalProperties": true, "type": "object" }, { @@ -1015,6 +1017,7 @@ "additionalProperties": false, "properties": { "moves": { + "additionalProperties": true, "title": "Moves", "type": "object" }, @@ -1044,6 +1047,7 @@ "additionalProperties": false, "properties": { "moves": { + "additionalProperties": true, "title": "Moves", "type": "object" }, From 5842edf436cfb33b4476b2a9e5a38b219af8af8f Mon Sep 17 00:00:00 2001 From: "Ware, Joseph (DLSLtd,RAL,LSCI)" <53935796+DiamondJoseph@users.noreply.github.com> Date: Mon, 14 Jul 2025 15:00:06 +0100 Subject: [PATCH 4/4] fix: Better handling for optional arguments --- src/blueapi/core/context.py | 12 +-- tests/system_tests/plans.json | 104 ++++++-------------------- tests/unit_tests/core/test_context.py | 70 +++++++++++++++++ 3 files changed, 101 insertions(+), 85 deletions(-) diff --git a/src/blueapi/core/context.py b/src/blueapi/core/context.py index d927f74ff6..26ed8da781 100644 --- a/src/blueapi/core/context.py +++ b/src/blueapi/core/context.py @@ -3,7 +3,7 @@ from dataclasses import dataclass, field from importlib import import_module from inspect import Parameter, signature -from types import ModuleType, UnionType +from types import ModuleType, NoneType, UnionType from typing import Any, Generic, TypeVar, Union, get_args, get_origin, get_type_hints from bluesky.protocols import HasName @@ -12,7 +12,7 @@ from ophyd_async.core import NotConnected from pydantic import GetCoreSchemaHandler, GetJsonSchemaHandler, create_model from pydantic.fields import FieldInfo -from pydantic.json_schema import JsonSchemaValue +from pydantic.json_schema import JsonSchemaValue, SkipJsonSchema from pydantic_core import CoreSchema, core_schema from blueapi import utils @@ -323,12 +323,12 @@ def _type_spec_for_function( no_default = para.default is Parameter.empty factory = None if no_default else DefaultFactory(para.default) new_args[name] = ( - self._convert_type(arg_type), + self._convert_type(arg_type, no_default), FieldInfo(default_factory=factory), ) return new_args - def _convert_type(self, typ: type | Any) -> type: + def _convert_type(self, typ: type | Any, no_default: bool = True) -> type: """ Recursively convert a type to something that can be deserialised by pydantic. Bluesky protocols (and types that extend them) are replaced @@ -344,12 +344,14 @@ def _convert_type(self, typ: type | Any) -> type: Returns: A Type that can be deserialised by Pydantic """ + if typ is NoneType and not no_default: + return SkipJsonSchema[NoneType] root = get_origin(typ) if is_bluesky_type(typ) or (root is not None and is_bluesky_type(root)): return self._reference(typ) args = get_args(typ) if args: - new_types = tuple(self._convert_type(i) for i in args) + new_types = tuple(self._convert_type(i, no_default) for i in args) if root == UnionType: root = Union return root[new_types] if root else typ # type: ignore diff --git a/tests/system_tests/plans.json b/tests/system_tests/plans.json index bd19637753..4f2075e2b6 100644 --- a/tests/system_tests/plans.json +++ b/tests/system_tests/plans.json @@ -37,16 +37,9 @@ "title": "Delay" }, "metadata": { - "anyOf": [ - { - "additionalProperties": true, - "type": "object" - }, - { - "type": "null" - } - ], - "title": "Metadata" + "additionalProperties": true, + "title": "Metadata", + "type": "object" } }, "required": [ @@ -89,7 +82,7 @@ }, "radius": { "description": "Radius of the circle", - "exclusiveMinimum": 0, + "exclusiveMinimum": 0.0, "title": "Radius", "type": "number" }, @@ -228,18 +221,18 @@ }, "x_radius": { "description": "The radius along the x axis of the ellipse", - "exclusiveMinimum": 0, + "exclusiveMinimum": 0.0, "title": "X Radius", "type": "number" }, "y_radius": { "description": "The radius along the y axis of the ellipse", - "exclusiveMinimum": 0, + "exclusiveMinimum": 0.0, "title": "Y Radius", "type": "number" }, "angle": { - "default": 0, + "default": 0.0, "description": "The angle of the ellipse (degrees)", "title": "Angle", "type": "number" @@ -510,7 +503,7 @@ "type": "number" }, "angle": { - "default": 0, + "default": 0.0, "description": "Clockwise rotation angle of the rectangle", "title": "Angle", "type": "number" @@ -724,7 +717,7 @@ "type": "integer" }, "rotate": { - "default": 0, + "default": 0.0, "description": "How much to rotate the angle of the spiral", "title": "Rotate", "type": "number" @@ -908,16 +901,9 @@ "$ref": "#/$defs/Spec" }, "metadata": { - "anyOf": [ - { - "additionalProperties": true, - "type": "object" - }, - { - "type": "null" - } - ], - "title": "Metadata" + "additionalProperties": true, + "title": "Metadata", + "type": "object" } }, "required": [ @@ -946,15 +932,8 @@ "title": "Value" }, "group": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "title": "Group" + "title": "Group", + "type": "string" }, "wait": { "title": "Wait", @@ -987,15 +966,8 @@ "title": "Value" }, "group": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "title": "Group" + "title": "Group", + "type": "string" }, "wait": { "title": "Wait", @@ -1022,15 +994,8 @@ "type": "object" }, "group": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "title": "Group" + "title": "Group", + "type": "string" } }, "required": [ @@ -1052,15 +1017,8 @@ "type": "object" }, "group": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "title": "Group" + "title": "Group", + "type": "string" } }, "required": [ @@ -1095,26 +1053,12 @@ "additionalProperties": false, "properties": { "group": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "title": "Group" + "title": "Group", + "type": "string" }, "timeout": { - "anyOf": [ - { - "type": "number" - }, - { - "type": "null" - } - ], - "title": "Timeout" + "title": "Timeout", + "type": "number" } }, "title": "wait", diff --git a/tests/unit_tests/core/test_context.py b/tests/unit_tests/core/test_context.py index dab1b03df3..58031178ad 100644 --- a/tests/unit_tests/core/test_context.py +++ b/tests/unit_tests/core/test_context.py @@ -2,6 +2,7 @@ from dataclasses import dataclass from pathlib import Path +from types import NoneType from typing import Generic, TypeVar, Union from unittest.mock import patch @@ -26,6 +27,7 @@ from ophyd_async.epics.adaravis import AravisDetector from ophyd_async.epics.motor import Motor from pydantic import TypeAdapter, ValidationError +from pydantic.json_schema import SkipJsonSchema from pytest import LogCaptureFixture from blueapi.config import EnvironmentConfig, MetadataConfig, Source, SourceKind @@ -431,6 +433,18 @@ def test_reference_type_conversion_new_style_union( assert empty_context._convert_type(Movable | int) == movable_ref | int +def test_reference_type_conversion_new_style_optional( + empty_context: BlueskyContext, +): + movable_ref: type = empty_context._reference(Movable) + assert empty_context._convert_type(Movable) == movable_ref + assert empty_context._convert_type(Movable | None) == movable_ref | None + assert ( + empty_context._convert_type(Movable | None, no_default=False) + == movable_ref | SkipJsonSchema[NoneType] + ) + + def test_default_device_reference(empty_context: BlueskyContext): def default_movable(mov: Movable = inject("demo")) -> MsgGenerator: yield from () @@ -596,3 +610,59 @@ class InnerClass: ... @pytest.mark.parametrize("type,expected", qualified_name_test_data) def test_qualified_name_with_types(type: type, expected: str): assert qualified_name(type) == expected + + +def test_optional_arg_generated_schema( + empty_context: BlueskyContext, +): + def demo_plan(foo: int | None = None) -> MsgGenerator: + yield from () + + empty_context.register_plan(demo_plan) + schema = empty_context.plans["demo_plan"].model.model_json_schema() + assert schema["properties"] == { + "foo": {"title": "Foo", "type": "integer"}, + } + assert "foo" not in schema.get("required", []) + + +def test_overloaded_arg_generated_schema( + empty_context: BlueskyContext, +): + def demo_plan(foo: int | str) -> MsgGenerator: + yield from () + + empty_context.register_plan(demo_plan) + schema = empty_context.plans["demo_plan"].model.model_json_schema() + assert schema["properties"] == { + "foo": {"title": "Foo", "anyOf": [{"type": "integer"}, {"type": "string"}]} + } + assert "foo" in schema.get("required", []) + + +def test_optional_overloaded_arg_generated_schema( + empty_context: BlueskyContext, +): + def demo_plan(foo: int | str | None = None) -> MsgGenerator: + yield from () + + empty_context.register_plan(demo_plan) + schema = empty_context.plans["demo_plan"].model.model_json_schema() + assert schema["properties"] == { + "foo": {"title": "Foo", "anyOf": [{"type": "integer"}, {"type": "string"}]} + } + assert "foo" not in schema.get("required", []) + + +def test_explicit_none_arg_generated_schema( + empty_context: BlueskyContext, +): + def demo_plan(foo: int | None) -> MsgGenerator: + yield from () + + empty_context.register_plan(demo_plan) + schema = empty_context.plans["demo_plan"].model.model_json_schema() + assert schema["properties"] == { + "foo": {"title": "Foo", "anyOf": [{"type": "integer"}, {"type": "null"}]} + } + assert "foo" in schema.get("required", [])