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
55 changes: 45 additions & 10 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -92,19 +92,54 @@ 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 enforce normative UCP constraints (capability profile composition,
directional `ucp_request` / `ucp_response` visibility, `dependentRequired`,
`if`/`then`, `uniqueItems`, `contains`, `propertyNames`, `not.enum`, and
closed-schema rules):

1. **In-Memory Runtime Validation (Production Services)**: Compile your UCP
discovery profile once at build or startup time into a standard JSON Schema
Draft 2020-12 bundle
(`ucp-schema generate-types --profile profile.json -o ucp.types.json`) or
OpenAPI 3.1 specification
(`ucp-schema generate-openapi --profile profile.json -o openapi.json`).
Because `ucp-schema` resolves all UCP-specific annotations and capability
extensions into standard JSON Schema 2020-12, you can validate incoming
payloads in memory on every request using
[`jsonschema`](https://python-jsonschema.readthedocs.io/) or OpenAPI ASGI
middleware before deserializing into Pydantic DTOs:

```python
from pydantic import ValidationError
import json
from pathlib import Path
from jsonschema import Draft202012Validator
from ucp_sdk.models import Checkout

# Validate data against UCP schemas
try:
checkout = Checkout.model_validate(checkout_data)
# Serialize to JSON-compatible dict
checkout_dict = checkout.model_dump(exclude_none=True)
except ValidationError as e:
print(e.errors())
# At startup: load the pre-compiled schema bundle once into memory
bundle = json.loads(Path("ucp.types.json").read_text(encoding="utf-8"))
checkout_validator = Draft202012Validator(
{**bundle["$defs"]["Checkout"], "$defs": bundle["$defs"]}
)

# Per request: validate in-memory, then parse into typed Pydantic DTO
checkout_validator.validate(raw_payload)
checkout = Checkout.model_validate(raw_payload)
```

2. **CLI & CI Conformance Testing**: Verify fixture payloads or live traffic
dumps from the command line or CI pipelines with
[`ucp-schema validate`](https://crates.io/crates/ucp-schema):

```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
```

## Development
Expand Down Expand Up @@ -145,7 +180,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