Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
90 changes: 23 additions & 67 deletions src/labthings_fastapi/thing_description/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,7 @@
and thus is not a local reference.
"""
if not reference.startswith("#/"):
raise NotImplementedError(

Check warning on line 68 in src/labthings_fastapi/thing_description/__init__.py

View workflow job for this annotation

GitHub Actions / coverage

68 line is not covered with tests
"Built-in resolver can only dereference internal JSON references "
"(i.e. starting with #)."
)
Expand All @@ -74,43 +74,12 @@
for key in reference[2:].split("/"):
resolved = resolved[key]
return resolved
except KeyError as ke:
raise KeyError(

Check warning on line 78 in src/labthings_fastapi/thing_description/__init__.py

View workflow job for this annotation

GitHub Actions / coverage

77-78 lines are not covered with tests
f"The JSON reference {reference} was not found in the schema."
) from ke


def is_an_object(d: JSONSchema) -> bool:
"""Determine whether a JSON schema dict is an object.

:param d: a chunk of JSONSchema describing a datatype.

:return: ``True`` if the ``type`` is ``object``.
"""
return "type" in d and d["type"] == "object"


def convert_object(d: JSONSchema) -> JSONSchema:
"""Convert an object from JSONSchema to Thing Description.

Convert JSONSchema objects to Thing Description datatypes.

Currently, this deletes the ``additionalProperties`` keyword, which is
not supported by Thing Description.

:param d: the JSONSchema object.

:return: a copy of ``d``, with ``additionalProperties`` deleted.
"""
out: JSONSchema = d.copy()
# AdditionalProperties is not supported by Thing Description, and it is ambiguous
# whether this implies it's false or absent. I will, for now, ignore it, so we
# delete the key below.
if "additionalProperties" in out:
del out["additionalProperties"]
return out


def convert_anyof(d: JSONSchema) -> JSONSchema:
"""Convert the anyof key to oneof.

Expand All @@ -133,25 +102,36 @@


def convert_prefixitems(d: JSONSchema) -> JSONSchema:
"""Convert the prefixitems key to items.
r"""Convert the prefixitems key to items.

The ``items`` key in an ArraySchema_ may work in two ways:

JSONSchema 2019 (as used by thing description) used
`items` with a list of values in the same way that JSONSchema
now uses `prefixitems`.
* If it is a `DataSchema` instance, then it specifies the schema for
all items in the array. This would apply to ``list[int]`` for example.
* If it is a sequence of `DataSchema` instances, then we are describing
a `tuple` with a defined number of elements, each with a defined type,
for example `tuple[int, str]`\ .

JSONSchema 2020 uses `items` to mean the same as `additionalItems`
in JSONSchema 2019 - but Thing Description doesn't support the
`additionalItems` keyword. This will result in us overwriting
additional items, and we raise a ValueError if that happens.
JSONSchema 2020 uses `items` to mean the type of subsequent items (i.e.
``list[...]``), so it's only ever a single type. When describing a `tuple`
it will use ``prefixItems`` to give the type of each element.

This behaviour may be relaxed in the future.
JSONSchema 2019 (on which ArraySchema_ is based) has a different definition
again, using ``items`` and ``additionalItems``\ .

We are converting from JSONSchema 2020 (which is what `pydantic` generates)
to ArraySchema_ so we will convert ``prefixItems`` into ``items``\ . If
both keys are present, we will raise an error, as that's not currently
supported.

:param d: the JSONSchema object.

:return: a copy of ``d``, converted to 2019 format as above.

:raise KeyError: if we would overwrite an existing ``items``
key.

.. _ArraySchema: https://www.w3.org/TR/wot-thing-description/#arrayschema
"""
if "prefixItems" not in d:
return d
Expand All @@ -163,39 +143,18 @@
return out


def convert_additionalproperties(d: JSONSchema) -> JSONSchema:
r"""Move additionalProperties into properties, or remove it.

JSONSchema uses ``additionalProperties`` to define optional properties
of ``object``\ s. For Thing Descriptions, this should be moved inside
the ``properties`` object.

:param d: the JSONSchema object.

:return: a copy of ``d``, with ``additionalProperties`` moved into
``properties`` or deleted if ``properties`` is not present.
"""
if "additionalProperties" not in d:
return d
out: JSONSchema = d.copy()
if "properties" in out and "additionalProperties" not in out["properties"]:
out["properties"]["additionalProperties"] = out["additionalProperties"]
del out["additionalProperties"]
return out


def check_recursion(depth: int, limit: int) -> None:
"""Check the recursion count is less than the limit.

:param depth: the current recursion depth.
:param limit: the maximum recursion depth.

:raise ValueError: if we exceed the recursion depth.
:raise RecursionError: if we exceed the recursion depth.
"""
if depth > limit:
raise ValueError(
raise RecursionError(
f"Recursion depth of {limit} exceeded - perhaps there is a circular "
"reference?"
"reference in a `pydantic` model?"
)


Expand Down Expand Up @@ -250,11 +209,8 @@
recursion_depth += 1
check_recursion(recursion_depth, recursion_limit)

if is_an_object(d):
d = convert_object(d)
d = convert_anyof(d)
d = convert_prefixitems(d)
d = convert_additionalproperties(d)

# After checking the object isn't a reference, we now recursively check
# sub-dictionaries and dereference those if necessary. This could be done with a
Expand Down
2 changes: 1 addition & 1 deletion src/labthings_fastapi/thing_description/_model.py
Original file line number Diff line number Diff line change
Expand Up @@ -104,7 +104,7 @@
raise ValueError(f"{v} must be {THING_CONTEXT_URL}")
else:
if len(v) == 0:
raise ValueError("The context can't be an empty list.")

Check warning on line 107 in src/labthings_fastapi/thing_description/_model.py

View workflow job for this annotation

GitHub Actions / coverage

107 line is not covered with tests
if v[0] == THING_CONTEXT_URL:
if THING_CONTEXT_URL_v1 in v[1:]:
raise ValueError("An old context is given after the current one.")
Expand Down Expand Up @@ -180,7 +180,7 @@
contentEncoding: Optional[str] = None
contentMediaType: Optional[str] = None

model_config = ConfigDict(extra="forbid")
model_config = ConfigDict(extra="allow")


class Response(BaseModel):
Expand Down
Loading
Loading