Skip to content
Open
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
21 changes: 15 additions & 6 deletions .github/workflows/tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ permissions:

jobs:
schema-preprocessing:
name: Schema preprocessing (Python ${{ matrix.python-version }})
name: Unit tests (Python ${{ matrix.python-version }})
runs-on: ubuntu-latest
strategy:
fail-fast: false
Expand All @@ -42,7 +42,7 @@ jobs:
python-version: ${{ matrix.python-version }}
- name: Install package (generated-model tests import it)
run: pip install -e .
- name: Run preprocessing tests
- name: Run unit tests
run: python -m unittest discover -s tests -p "test_*.py"

model-drift:
Expand All @@ -58,10 +58,19 @@ jobs:
with:
enable-cache: true
- name: Install ucp-schema CLI
# Pinned to an immutable commit on main + Cargo.lock (includes
# ucp-schema#81-#89); switch to `cargo install ucp-schema@<version>
# --locked` once a crates.io release > 1.4.1 is published.
run: cargo install --git https://github.com/Universal-Commerce-Protocol/ucp-schema.git --rev 2ce04ccdcd529416808160b8419e97a91483a1f7 --locked ucp-schema
env:
UCP_SCHEMA_VERSION: "2.0.0"
run: |
set -euo pipefail
tmp_dir="$(mktemp -d)"
trap 'rm -rf "$tmp_dir"' EXIT
base_url="https://github.com/Universal-Commerce-Protocol/ucp-schema/releases/download/v${UCP_SCHEMA_VERSION}"
curl -fsSL --retry 3 -o "$tmp_dir/ucp-schema-linux-x86_64" "${base_url}/ucp-schema-linux-x86_64"
curl -fsSL --retry 3 -o "$tmp_dir/SHA256SUMS" "${base_url}/SHA256SUMS"
(cd "$tmp_dir" && sha256sum -c --ignore-missing SHA256SUMS)
mkdir -p "$HOME/.local/bin"
install -m 0755 "$tmp_dir/ucp-schema-linux-x86_64" "$HOME/.local/bin/ucp-schema"
echo "$HOME/.local/bin" >> "$GITHUB_PATH"

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Two notes here:

  1. Upstream release dependency: model-drift currently fails with HTTP 404 because Universal-Commerce-Protocol/ucp-schema v2.0.0 hasn't been published yet (waiting on Universal-Commerce-Protocol/ucp-schema#96 and Universal-Commerce-Protocol/ucp-schema#97 to merge and v2.0.0 to be tagged).
  2. Workflow script hygiene (nit): To keep .github/workflows/*.yml declarative with single-line run: steps, consider moving the ucp-schema release binary download + SHA256SUMS verification into a dedicated script (or generate_models.sh), and removing the duplicate Normalize file endings inline Python step below (lines 78–88), since generate_models.sh already normalizes file endings on src/ucp_sdk/models.

- name: Regenerate models from the pinned spec version
# SDK 0.6.x targets UCP 2026-08-25 (see the README compatibility
# table). Bump this pin together with the SDK version line.
Expand Down
39 changes: 34 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -92,16 +92,45 @@ both `ucp_sdk.models` and `ucp_sdk.models.schemas`:

### Validation

All models support Pydantic validation and serialization:
Pydantic models in `ucp_sdk.models` provide Tier 1 wire DTO structural typing
and tolerant-reader deserialization (preserving unknown extension fields for
forward compatibility) rather than full UCP protocol conformance.

To validate inbound messages against normative UCP constraints (capability
profile composition, directional `ucp_request` / `ucp_response` visibility,
`dependentRequired`, `if`/`then`, `uniqueItems`, `contains`, `propertyNames`,
`not.enum`, and closed-schema rules), verify received JSON payloads with
[`ucp-schema validate`](https://crates.io/crates/ucp-schema) prior to
deserializing into SDK DTOs:

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit (non-blocking): Worth adding a brief note here that open discriminated unions (e.g., FulfillmentDestination, FulfillmentMethod, Provider) are emitted as untagged unions with an open *Base fallback (type: str), so a payload with a known type that fails a variant-specific field check will deserialize into *Base unless validated upfront with ucp-schema validate.


```bash
# Self-describing response or JSON-RPC envelope
ucp-schema validate payload.json --op read

# REST request against a discovery profile (or --schema checkout.json --request --op create)
ucp-schema validate payload.json --profile profile.json --op create
```

```python
from pathlib import Path
import subprocess

from pydantic import ValidationError
from ucp_sdk.models import Checkout

# Validate data against UCP schemas
payload_path = Path("payload.json")

# Step 1: Verify normative UCP protocol constraints with ucp-schema validate
subprocess.run(
["ucp-schema", "validate", str(payload_path), "--op", "read"],
check=True,
)

# Step 2: Deserialize verified JSON into typed Pydantic DTOs
try:
checkout = Checkout.model_validate(checkout_data)
# Serialize to JSON-compatible dict
checkout = Checkout.model_validate_json(
payload_path.read_text(encoding="utf-8")
)
checkout_dict = checkout.model_dump(exclude_none=True)
except ValidationError as e:
print(e.errors())
Expand Down Expand Up @@ -145,7 +174,7 @@ a branch or commit SHA, or a path to a local UCP repository checkout.
If no argument is specified, the `main` branch of the
[UCP repo](https://github.com/Universal-Commerce-Protocol/ucp) will be used.

The generated code is automatically post-processed and formatted using `ruff`.
The generated code is automatically formatted using `ruff`.

### Running Tests

Expand Down
50 changes: 46 additions & 4 deletions generate_models.sh
Original file line number Diff line number Diff line change
Expand Up @@ -137,8 +137,7 @@ echo "Compiling type bundle with ucp-schema generate-types..."
"$UCP_SCHEMA_BIN" generate-types --schema-dir "$SCOPED_SCHEMA_DIR" --output "$TMP_TYPES_JSON"

echo "Generating Pydantic models from compiled type bundle..."
rm -rf "$OUTPUT_DIR"
mkdir -p "$OUTPUT_DIR"
mkdir -p "$OUTPUT_DIR" && rm -f "$OUTPUT_DIR/_generated.py"

uv run \
--link-mode=copy \
Expand All @@ -161,8 +160,51 @@ uv run \
--use-title-as-name \
--additional-imports pydantic.ConfigDict

echo "Post-processing generated models (constraints the generator ignores)..."
uv run python postprocess_models.py "$TMP_TYPES_JSON" src/ucp_sdk/models "$SCOPED_SCHEMA_DIR"
echo "Generating lazy package index in $OUTPUT_DIR/__init__.py..."
python3 - <<'PY'
import ast
from pathlib import Path

_SYNTHETIC_WRAPPERS = {"UCPSchemaTypes", "UcpSchemaTypes"}
schemas_dir = Path("src/ucp_sdk/models/schemas")
tree = ast.parse((schemas_dir / "_generated.py").read_text(encoding="utf-8"))
type_alias_cls = getattr(ast, "TypeAlias", ())
names: set[str] = set()
for node in tree.body:
if isinstance(node, ast.ClassDef):
names.add(node.name)
elif isinstance(node, ast.Assign):
names.update(t.id for t in node.targets if isinstance(t, ast.Name))
elif isinstance(node, ast.AnnAssign) and isinstance(node.target, ast.Name):
names.add(node.target.id)
elif isinstance(node, type_alias_cls) and isinstance(getattr(node, "name", None), ast.Name):
names.add(node.name.id)
exports = sorted(n for n in names if not n.startswith("_") and n not in _SYNTHETIC_WRAPPERS)
imports = ", ".join(exports)
all_items = ", ".join(f'"{n}"' for n in exports)
(schemas_dir / "__init__.py").write_text(
'# generated by generate_models.sh\n\n'

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: Including the Apache 2.0 license header at the top of generated src/ucp_sdk/models/schemas/__init__.py (as _write_schemas_reexport_init previously did) would keep license header consistency across src/ucp_sdk/.

'"""Generated UCP schema models."""\n\n'
'from __future__ import annotations\n\n'
'import importlib\n'
'from typing import TYPE_CHECKING, Any\n\n'
f'if TYPE_CHECKING:\n from ._generated import ({imports},)\n\n'
f'__all__ = [{all_items},]\n\n\n'
'def __getattr__(name: str) -> Any:\n'
' """Import public schema models and type aliases on first access."""\n'
' if name not in __all__:\n'
' msg = f"module {__name__!r} has no attribute {name!r}"\n'
' raise AttributeError(msg)\n'
' module = importlib.import_module("._generated", __name__)\n'
' value = getattr(module, name)\n'
' globals()[name] = value\n'
' return value\n\n\n'
'def __dir__() -> list[str]:\n'
' """Return module attributes including lazily re-exported schema names."""\n'
' return sorted({*globals(), *__all__})\n',
encoding="utf-8",
)
PY

echo "Formatting generated models..."
uv run ruff check --fix src/ucp_sdk/models
Expand Down
Loading
Loading