diff --git a/README.md b/README.md index 7971d07..caebfd4 100644 --- a/README.md +++ b/README.md @@ -604,6 +604,7 @@ Notes: - JSON uses top-level `width`, `height`, and `layers` - Named custom layers added with `canvas.custom(fn, name="...", kwargs={...})` are JSON-serializable via the registry; unnamed custom layers are not - Enum-like values such as `blend_mode`, `fit`, and `align` can be passed as strings +- `quickthumb schema` emits the current JSON Schema for constrained generation and editor autocomplete ## AI-Friendly Workflows @@ -626,6 +627,8 @@ Use one background image layer, one dark overlay background layer, two text laye Only use valid quickthumb layer types and effect names. ``` +For constrained generation, first run `quickthumb schema > quickthumb.schema.json` and pass that schema to the model or editor. Prefer concrete field values in schema-constrained calls; `$theme.*` tokens are resolved by quickthumb before model validation and may not satisfy generic JSON Schema validators by themselves. + Recommended workflow: 1. Have the model produce quickthumb Python or JSON. diff --git a/docs/json-schema.md b/docs/json-schema.md index fb16b83..69d22eb 100644 --- a/docs/json-schema.md +++ b/docs/json-schema.md @@ -39,6 +39,36 @@ A quickthumb JSON document has three required top-level fields, plus an optional Every layer object requires a `"type"` discriminator field. Layers render in array order — first item is backmost. +## Published schema + +Emit the current JSON Schema from the same Pydantic models used by `Canvas.from_json()`: + +```bash +quickthumb schema > quickthumb.schema.json +quickthumb schema --output quickthumb.schema.json +``` + +!!! note "Schema scope" + `quickthumb schema` describes concrete quickthumb specs for external tooling + and constrained generation. `Canvas.from_json()` remains the source of truth + for what quickthumb can load. + + Authoring conveniences such as `$theme.*` are resolved by quickthumb before + model validation, so generic JSON Schema validators may reject unresolved + authoring specs that quickthumb itself can load. + +The command writes deterministic JSON only, so it can be checked into a repo, piped into an editor, or passed directly to a constrained-generation API. The schema includes the current canvas fields, built-in layer discriminators, effects, animations, supported platform presets, and the optional top-level `theme` block. + +For constrained generation, prefer concrete resolved values in typed fields: + +```text +Use quickthumb.schema.json as the JSON response schema. +Generate one valid quickthumb canvas spec for a 1280x720 YouTube thumbnail. +Return only the JSON object. +Use concrete hex colors and numeric sizes in layer fields. +Use only these layer type discriminators: background, text, image, shape, svg, group, outline. +``` + ## Theme tokens Define brand tokens once in a top-level `theme` block and reference them anywhere in the spec with `$theme.path`: diff --git a/quickthumb/__init__.py b/quickthumb/__init__.py index 39cb503..71e29b3 100644 --- a/quickthumb/__init__.py +++ b/quickthumb/__init__.py @@ -44,6 +44,7 @@ Wheel, Wipe, ) +from quickthumb.schema import canvas_json_schema from quickthumb.transitions import Transition __all__ = [ @@ -94,5 +95,6 @@ "TextLayer", "TextPart", "Transition", + "canvas_json_schema", "transitions", ] diff --git a/quickthumb/cli.py b/quickthumb/cli.py index fddee97..219bf28 100644 --- a/quickthumb/cli.py +++ b/quickthumb/cli.py @@ -9,6 +9,7 @@ from quickthumb.canvas import _VAR_RE, Canvas, _is_theme_reference from quickthumb.errors import RenderingError, ValidationError +from quickthumb.schema import canvas_json_schema _VALID_FORMATS = {"PNG", "JPEG", "WEBP"} @@ -66,6 +67,27 @@ def main() -> None: app() +@app.command() +def schema( + output: Annotated[ + Path | None, + typer.Option("-o", "--output", help="Write schema JSON to a file instead of stdout"), + ] = None, +) -> None: + """Emit the JSON Schema for quickthumb canvas specs.""" + payload = json.dumps(canvas_json_schema(), indent=2, sort_keys=True) + "\n" + if output is None: + typer.echo(payload, nl=False) + return + + try: + output.write_text(payload) + except OSError as e: + typer.echo(str(e), err=True) + raise typer.Exit(1) from e + typer.echo(str(output)) + + @app.command() def render( spec: Annotated[Path, typer.Argument(help="Path to a JSON spec file")], diff --git a/quickthumb/models.py b/quickthumb/models.py index 281c837..664a06c 100644 --- a/quickthumb/models.py +++ b/quickthumb/models.py @@ -12,6 +12,7 @@ NonNegativeInt, PositiveFloat, PositiveInt, + WithJsonSchema, field_serializer, field_validator, model_validator, @@ -21,6 +22,18 @@ from quickthumb.errors import ValidationError HEX_COLOR_PATTERN = r"^#[0-9A-Fa-f]{6}([0-9A-Fa-f]{2})?$" +PERCENT_COORDINATE_PATTERN = r"^-?(\d+(\.\d+)?)%$" +POSITIVE_PERCENT_PATTERN = r"^(\d+(\.\d+)?)%$" + +PercentCoordinate = Annotated[ + str, + WithJsonSchema({"type": "string", "pattern": PERCENT_COORDINATE_PATTERN}), +] +PositivePercent = Annotated[ + str, + WithJsonSchema({"type": "string", "pattern": POSITIVE_PERCENT_PATTERN}), +] +Position = tuple[int | PercentCoordinate, int | PercentCoordinate] def validate_hex_color(color: str) -> str: @@ -31,7 +44,11 @@ def validate_hex_color(color: str) -> str: # Reusable color type with validation -HexColor = Annotated[str, AfterValidator(validate_hex_color)] +HexColor = Annotated[ + str, + AfterValidator(validate_hex_color), + WithJsonSchema({"type": "string", "pattern": HEX_COLOR_PATTERN}), +] def _validate_opacity(v: float) -> float: @@ -40,7 +57,11 @@ def _validate_opacity(v: float) -> float: return v -OpacityField = Annotated[float, AfterValidator(_validate_opacity)] +OpacityField = Annotated[ + float, + AfterValidator(_validate_opacity), + WithJsonSchema({"type": "number", "minimum": 0.0, "maximum": 1.0}), +] # Generic enum converter @@ -143,7 +164,15 @@ def _validate_align_with_hv_tuple(v: Any) -> Align | None: raise ValueError(f"invalid align value: {v}") -AlignWithHVTuple = Annotated[Align | None, BeforeValidator(_validate_align_with_hv_tuple)] +AlignWithHVTuple = Annotated[ + Align | None, + BeforeValidator( + _validate_align_with_hv_tuple, + json_schema_input_type=Align + | tuple[Literal["left", "center", "right"], Literal["top", "middle", "bottom"]] + | None, + ), +] class quickthumbModel(BaseModel): # noqa: N801 @@ -182,7 +211,7 @@ class TextFillImage(quickthumbModel): type: Literal["image"] = "image" path: str fit: Annotated[ - FitMode | str, AfterValidator(lambda v: enum_converter(FitMode)(v) if v else FitMode.COVER) + FitMode, BeforeValidator(lambda v: enum_converter(FitMode)(v) if v else FitMode.COVER) ] = FitMode.COVER @@ -471,12 +500,12 @@ class TextLayer(quickthumbModel): size: PositiveInt | None = None color: HexColor | None = None fill: TextFill | None = None - position: tuple | None = None + position: Position | None = None align: AlignWithHVTuple = None bold: bool = False italic: bool = False weight: int | str | None = None - max_width: int | str | None = None + max_width: int | PositivePercent | None = None effects: list[TextEffect] = [] line_height: PositiveFloat | None = None letter_spacing: int | None = None @@ -526,7 +555,7 @@ def validate_auto_scale_requires_max_width(self) -> "TextLayer": @field_validator("position", mode="before") @classmethod - def validate_position(cls, v: tuple | list | None) -> tuple | None: + def validate_position(cls, v: tuple | list | None) -> Position | None: if v is None: return v @@ -561,7 +590,7 @@ class OutlineLayer(quickthumbModel): class ImageLayer(quickthumbModel): type: Literal["image"] path: str - position: tuple + position: Position width: PositiveInt | None = None height: PositiveInt | None = None opacity: OpacityField = 1.0 @@ -580,7 +609,7 @@ class ImageLayer(quickthumbModel): @field_validator("position", mode="before") @classmethod - def validate_position(cls, v: tuple | list | None) -> tuple | None: + def validate_position(cls, v: tuple | list | None) -> Position | None: if v is None: raise ValueError("position is required") @@ -605,7 +634,7 @@ def serialize_align(self, align: Align) -> str: class ShapeLayer(quickthumbModel): type: Literal["shape"] shape: Literal["rectangle", "ellipse", "pill", "triangle", "star", "polygon"] - position: tuple + position: Position width: PositiveInt height: PositiveInt color: HexColor @@ -657,7 +686,7 @@ def validate_points_match_shape(self) -> "ShapeLayer": @field_validator("position", mode="before") @classmethod - def validate_position(cls, v: tuple | list | None) -> tuple | None: + def validate_position(cls, v: tuple | list | None) -> Position | None: if v is None: raise ValueError("position is required") @@ -683,7 +712,7 @@ def serialize_align(self, align: Align | None) -> str | None: class SvgLayer(quickthumbModel): type: Literal["svg"] path: str - position: tuple + position: Position width: PositiveInt | None = None height: PositiveInt | None = None opacity: OpacityField = 1.0 @@ -697,7 +726,7 @@ class SvgLayer(quickthumbModel): @field_validator("position", mode="before") @classmethod - def validate_position(cls, v: tuple | list | None) -> tuple | None: + def validate_position(cls, v: tuple | list | None) -> Position | None: if v is None: raise ValueError("position is required") @@ -723,7 +752,7 @@ class GroupLayer(quickthumbModel): direction: Literal["row", "column"] = "column" gap: NonNegativeInt = 0 padding: int | tuple[int, int] | tuple[int, int, int, int] = 0 - position: tuple | None = None + position: Position | None = None align: AlignWithHVTuple = None item_align: Literal["start", "center", "end"] = "start" animation: AnimationInput | None = None @@ -775,7 +804,7 @@ def validate_padding( @field_validator("position", mode="before") @classmethod - def validate_position(cls, v: tuple | list | None) -> tuple | None: + def validate_position(cls, v: tuple | list | None) -> Position | None: if v is None: return v @@ -877,3 +906,11 @@ class CanvasModel(quickthumbModel): height: PositiveInt | None = None platform: str | None = None layers: list[LayerType] + + +class CanvasSpecModel(quickthumbModel): + width: PositiveInt | None = None + height: PositiveInt | None = None + platform: str | None = None + theme: dict[str, Any] = Field(default_factory=dict) + layers: list[LayerType] diff --git a/quickthumb/schema.py b/quickthumb/schema.py new file mode 100644 index 0000000..2231b23 --- /dev/null +++ b/quickthumb/schema.py @@ -0,0 +1,46 @@ +from __future__ import annotations + +from typing import Any + +from quickthumb._diagnostic_rules import PLATFORM_SAFE_MARGIN_PRESETS +from quickthumb.models import CanvasSpecModel + +JSON_SCHEMA_DRAFT = "https://json-schema.org/draft/2020-12/schema" +QUICKTHUMB_SCHEMA_ID = "https://sjquant.github.io/quickthumb/schema.json" + + +def canvas_json_schema() -> dict[str, Any]: + """Return the JSON Schema for quickthumb canvas specs.""" + schema = CanvasSpecModel.model_json_schema(mode="validation") + platform_name_schema = {"enum": sorted(PLATFORM_SAFE_MARGIN_PRESETS), "type": "string"} + platform_schema = { + "anyOf": [ + platform_name_schema, + {"type": "null"}, + ], + "default": None, + "title": "Platform", + } + sized_dimension_schema = {"exclusiveMinimum": 0, "type": "integer"} + schema["$schema"] = JSON_SCHEMA_DRAFT + schema["$id"] = QUICKTHUMB_SCHEMA_ID + schema["title"] = "quickthumb Canvas JSON Spec" + schema["description"] = ( + "A quickthumb canvas spec accepted by Canvas.from_json() and the quickthumb CLI." + ) + schema["properties"]["platform"] = platform_schema + schema["anyOf"] = [ + { + "properties": { + "width": sized_dimension_schema, + "height": sized_dimension_schema, + }, + "required": ["width", "height"], + }, + { + "properties": {"platform": platform_name_schema}, + "required": ["platform"], + "not": {"anyOf": [{"required": ["width"]}, {"required": ["height"]}]}, + }, + ] + return schema diff --git a/tests/test_cli.py b/tests/test_cli.py index 9d1f2c8..5a6080a 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -25,6 +25,155 @@ def spec_file(): os.unlink(f.name) +class TestCLISchema: + """Test suite for the quickthumb schema subcommand""" + + def test_should_emit_deterministic_json_schema_to_stdout(self): + """schema emits stable JSON only, suitable for shell pipelines""" + # given: the quickthumb CLI application + from quickthumb.cli import app + + runner = CliRunner() + + # when: the user asks for the published schema twice + first = runner.invoke(app, ["schema"]) + second = runner.invoke(app, ["schema"]) + + # then: stdout is deterministic parseable JSON with schema metadata + assert first.exit_code == 0 + assert second.exit_code == 0 + assert first.output == second.output + payload = json.loads(first.output) + assert payload["$schema"] == "https://json-schema.org/draft/2020-12/schema" + assert payload["$id"] == "https://sjquant.github.io/quickthumb/schema.json" + assert payload["title"] == "quickthumb Canvas JSON Spec" + + def test_should_include_canvas_theme_platform_and_layer_contracts(self): + """schema includes top-level spec fields and every JSON layer discriminator""" + # given: the quickthumb CLI application + from quickthumb.cli import app + + # when: the user emits the schema + result = CliRunner().invoke(app, ["schema"]) + + # then: the schema describes canvas fields, theme tokens, and layer types + assert result.exit_code == 0 + payload = json.loads(result.output) + assert set(payload["properties"]) == {"height", "layers", "platform", "theme", "width"} + assert payload["anyOf"][0]["properties"] == { + "width": {"exclusiveMinimum": 0, "type": "integer"}, + "height": {"exclusiveMinimum": 0, "type": "integer"}, + } + assert payload["anyOf"][0]["required"] == ["width", "height"] + assert payload["anyOf"][1] == { + "not": {"anyOf": [{"required": ["width"]}, {"required": ["height"]}]}, + "properties": { + "platform": { + "enum": [ + "instagram-reel", + "instagram-reels", + "instagram-square", + "tiktok", + "youtube", + "youtube-shorts", + "youtube-thumbnail", + ], + "type": "string", + } + }, + "required": ["platform"], + } + assert payload["properties"]["platform"]["anyOf"][0]["enum"] == [ + "instagram-reel", + "instagram-reels", + "instagram-square", + "tiktok", + "youtube", + "youtube-shorts", + "youtube-thumbnail", + ] + assert payload["properties"]["theme"]["type"] == "object" + layer_mapping = payload["properties"]["layers"]["items"]["discriminator"]["mapping"] + assert set(layer_mapping) == { + "background", + "group", + "image", + "outline", + "shape", + "svg", + "text", + } + + def test_should_reflect_model_validation_for_common_fields(self): + """schema preserves generated constraints from the public Pydantic models""" + # given: the quickthumb CLI application + from quickthumb.cli import app + + # when: the schema is emitted + result = CliRunner().invoke(app, ["schema"]) + + # then: generated field schemas include color, opacity, position, and align constraints + assert result.exit_code == 0 + defs = json.loads(result.output)["$defs"] + text_props = defs["TextLayer"]["properties"] + assert text_props["color"]["anyOf"][0]["pattern"] == "^#[0-9A-Fa-f]{6}([0-9A-Fa-f]{2})?$" + assert text_props["opacity"]["minimum"] == 0.0 + assert text_props["opacity"]["maximum"] == 1.0 + assert text_props["position"]["anyOf"][0]["minItems"] == 2 + assert text_props["position"]["anyOf"][0]["maxItems"] == 2 + position_item_options = text_props["position"]["anyOf"][0]["prefixItems"][0]["anyOf"] + assert position_item_options == [ + {"type": "integer"}, + {"pattern": "^-?(\\d+(\\.\\d+)?)%$", "type": "string"}, + ] + max_width_options = text_props["max_width"]["anyOf"] + assert max_width_options == [ + {"type": "integer"}, + {"pattern": "^(\\d+(\\.\\d+)?)%$", "type": "string"}, + {"type": "null"}, + ] + text_fill_image_props = defs["TextFillImage"]["properties"] + assert text_fill_image_props["fit"]["$ref"] == "#/$defs/FitMode" + align_options = text_props["align"]["anyOf"] + assert align_options[0] == {"$ref": "#/$defs/Align"} + assert align_options[1]["prefixItems"][0]["enum"] == ["left", "center", "right"] + assert align_options[1]["prefixItems"][1]["enum"] == ["top", "middle", "bottom"] + + def test_should_write_schema_to_output_path(self): + """schema --output writes the same script-friendly JSON to a file""" + # given: the quickthumb CLI application and an isolated output directory + from quickthumb.cli import app + + runner = CliRunner() + + # when: the user writes the schema to a file + with runner.isolated_filesystem(): + result = runner.invoke(app, ["schema", "--output", "schema.json"]) + + # then: the command prints the path and writes parseable schema JSON + assert result.exit_code == 0 + assert result.output.strip() == "schema.json" + with open("schema.json") as f: + payload = json.load(f) + assert payload["$schema"] == "https://json-schema.org/draft/2020-12/schema" + + def test_should_exit_1_when_schema_output_path_cannot_be_written(self): + """schema --output exits 1 when the target cannot be written as a file""" + # given: the quickthumb CLI application and a directory used as the output path + from quickthumb.cli import app + + runner = CliRunner() + + # when: the user asks schema to write to a directory + with runner.isolated_filesystem(): + os.mkdir("schema-dir") + result = runner.invoke(app, ["schema", "--output", "schema-dir"]) + + # then: the command exits 1 and surfaces the filesystem error + assert result.exit_code == 1 + assert "schema-dir" in result.output + + class TestCLIRender: def test_should_render_to_default_output(self, spec_file): """Test that render writes output.png in the current directory by default"""