Skip to content

Fix #166: derive examples from allOf compositions of non-object schemas - #177

Open
dstrodtman wants to merge 2 commits into
sphinx-contrib:masterfrom
dstrodtman:doc-1516-166-allof-scalar-example
Open

Fix #166: derive examples from allOf compositions of non-object schemas#177
dstrodtman wants to merge 2 commits into
sphinx-contrib:masterfrom
dstrodtman:doc-1516-166-allof-scalar-example

Conversation

@dstrodtman

@dstrodtman dstrodtman commented Aug 13, 2026

Copy link
Copy Markdown

Fixes #166.

example_from_schema merges each allOf subschema's example into a dict:

elif "allOf" in schema:
    example = {}
    for sub_schema in schema["allOf"]:
        example.update(example_from_schema(sub_schema))
    return example

That's valid only while every subschema is an object. A subschema of any other type resolves to a scalar or a list, and dict.update on those raises ValueError: dictionary update sequence element #0 has length 1; 2 is required, which aborts the build rather than degrading. The reporter's allOf toward a type: string component reproduces it exactly.

An instance of a schema composed with allOf from non-object subschemas is a value of that type, not a mapping — so the composed example is that value, and there is nothing to merge it into. The fix returns it.

Every subschema is still visited rather than returning at the first non-object one, so the result does not depend on the order subschemas are written in, and an explicitly provided example wins over one derived from a bare type. Returning early made allOf: [{type: string}, {type: string, example: "PENDING"}] yield "string" while the same two subschemas in the other order yielded "PENDING". A composition mixing an object with a non-object cannot be satisfied by any instance, so there is no right answer; it resolves to the non-object example and there is a test pinning that, rather than leaving it to be discovered.

There's a second way into the same crash, which the fix also has to handle: a subschema carrying annotations only.

allOf:
  - $ref: '#/components/schemas/SomeObject'
  - description: this only annotates the schema above

{"description": ...} has no type, so it hits the any-type branch and resolves to 1, and example.update(1) then raises TypeError: 'int' object is not iterable. Note that is a different exception from the ValueError in the issue title — same line, same cause, so worth knowing if you reproduce it and see a type error instead. Merging isn't the right answer either: an annotation-only subschema contributes no example, and if it were treated as a non-object subschema per the paragraph above, its 1 would shadow the composition's real example. So subschemas with none of the example-bearing keywords are skipped.

A boolean subschema is skipped as well — OAS 3.1 permits allOf: [true], and the keyword lookup raised AttributeError on it.

Two allOf semantics now coexist, and they agree here

Worth stating outright rather than leaving you to derive it: _httpdomain._resolve_combining_schema merges allOf keyword-wise to build the JSON schema description, while example_from_schema composes examples per subschema. Those are different operations on the same keyword, so it is fair to ask whether they can disagree on the same input.

The renderer test added here is evidence that they agree on the case that matters. For a status property composed toward a string enum, the description path emits :resjsonobj status: string:enum and the example path emits "status": "PENDING" in the same rendered output — the marker and the example body describe the same value.

Why this shows up a lot

The idiom that triggers it is what code generators emit for a documented enum property — a $ref wrapped in allOf so a sibling description can attach, since $ref siblings are ignored in OAS 3.0:

properties:
  status:
    description: The status of the job.
    allOf:
      - $ref: '#/components/schemas/JobStatus'

JobStatus:
  type: string
  enum: [PENDING, RUNNING, STOPPED, SUCCEEDED, FAILED]

Ray's Jobs API spec hits it twice this way, from two separate generated enum properties. A sibling allOf toward an actual object in the same schema is fine, which isolates it to the non-object target rather than to allOf. So I would expect this to affect generated specs fairly broadly rather than being a corner case.

Measured on a generator-shaped spec

Beyond the unit tests, I built a spec with the shape described above — two type: string enums referenced through allOf with a sibling description, plus one allOf toward a real object carrying an annotation-only sibling — and compared this branch against 0.9.0 installed from PyPI, running from a neutral working directory so that the checkout couldn't shadow the released package:

sphinx-build -W with :generate-examples-from-schemas:
  0.9.0        exit 2   ValueError: dictionary update sequence element #0 has length 1; 2 is required
  this branch  exit 0   no diagnostics

and the body renders:

{"type": "SUBMISSION", "status": "PENDING", "driver_info": {"id": "string"}}

Both the enum properties and the annotation-only composition come out right, which is the case that motivated the fix.

Tests

Two parametrized cases in tests/test_schema_utils.py cover allOf toward a string with an example, toward a string enum, toward an integer, toward an array, and the annotation-only subschema. One renderer test in tests/renderers/httpdomain/test_render_restructuredtext_markup.py covers the whole path with :generate-examples-from-schemas:, since the crash is only reachable through it. All three fail before the change.

Full suite passes (505), and pytest --regenerate-rendered-specs tests/ produces no fixture diff — none of the committed example specs use this idiom.

I also ran the issue's reproducer as a real Sphinx build: it aborts before the change and renders {"timestamp": "2022-09-12 13:09:16:18"} after.

Note on the neighbouring issues

This shares a seam with #172 and #168, which are both about how a scalar-derived example becomes a rendered body — #172 has a schema-derived string emitted unencoded into an application/json body, #168 has a datetime reaching json.dumps. I kept this PR to the crash in the composition step and did not touch the encoding step, so it is reviewable on its own. Happy to look at unifying the encoding decision separately if you think that is the right direction.

Context

Found while moving the Ray docs' Jobs API reference onto this extension. Together with #165 this is what currently keeps that reference from shipping example payloads at all.

'example_from_schema' merged every 'allOf' subschema's example into a dict.
That holds only while every subschema is an object. A subschema of any other
type yields a scalar or a list, and 'dict.update' on those raises
'ValueError: dictionary update sequence element #0 has length 1; 2 is
required', aborting the build.

An instance of a schema composed of non-object subschemas is a value of that
type, so return the subschema's example instead of merging it. Subschemas
that carry annotations only, a lone 'description' being the common case,
contribute no example and are skipped, since they'd otherwise resolve to the
any-type default and shadow the composition.

Signed-off-by: Douglas Strodtman <douglas@anyscale.com>
Returning the first non-object example short-circuited the loop, so the
outcome depended on where in 'allOf' a subschema sat: composing an explicit
'example' with a bare type yielded the bare type's default whenever the
latter came first. Visit every subschema instead, and prefer an explicitly
provided example over a derived one.

Also skip a boolean subschema, which OAS 3.1 permits and which raised
'AttributeError' on the keyword lookup, and pin the unsatisfiable
object-plus-non-object composition with a test.

Signed-off-by: Douglas Strodtman <douglas@anyscale.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

ValueError: dictionary update sequence element #0 has length 1; 2 is required

1 participant