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
13 changes: 9 additions & 4 deletions .github/workflows/tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -33,11 +33,11 @@ jobs:
python-version: ["3.10", "3.11", "3.12"]
steps:
- name: Checkout repository
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- name: Set up Python
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
with:
python-version: ${{ matrix.python-version }}
- name: Install package (generated-model tests import it)
Expand All @@ -50,15 +50,20 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- name: Install uv
uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0
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
- name: Regenerate models from the pinned spec version
# SDK 0.5.x targets UCP 2026-08-25 (see the README compatibility
# SDK 0.6.x targets UCP 2026-08-25 (see the README compatibility
# table). Bump this pin together with the SDK version line.
run: ./generate_models.sh 2026-08-25
- name: Normalize file endings (as pre-commit's end-of-file-fixer does)
Expand Down
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
node_modules/
.ruff_cache/
.mypy_cache/
.pytest_cache/
dist/
__pycache__/
.venv/
*.py[cod]
Expand Down
42 changes: 23 additions & 19 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,8 @@ UCP schema:

| SDK Version | UCP Schema Version |
| ----------------- | ------------------ |
| **`0.5.x`** | **`2026-08-25`** |
| **`0.6.x`** | **`2026-08-25`** |
| `0.5.x` | `2026-08-25` |
| `0.4.x` | `2026-04-08` |
| `0.3.x` | `2026-01-23` |
| `0.2.x` / `0.1.x` | `2026-01-11` |
Expand All @@ -66,7 +67,7 @@ uv add ucp-sdk
The example below parses a UCP checkout response and reads typed fields:

```python
from ucp_sdk.models.schemas.shopping.checkout import Checkout
from ucp_sdk.models import Checkout

# Parse a UCP checkout response
checkout = Checkout.model_validate(checkout_data)
Expand All @@ -80,22 +81,22 @@ for item in checkout.line_items:

### Available model packages

| Package | Description |
| --------------------------------------- | ----------------------------------------------------------------- |
| `ucp_sdk.models.schemas.common` | Location search/lookup, identity linking, loyalty, payment terms |
| `ucp_sdk.models.schemas.common.types` | Shared primitives (amounts, prices, totals, postal address, etc.) |
| `ucp_sdk.models.schemas.shopping` | Checkout, cart, catalog, order, buyer consent, permalink models |
| `ucp_sdk.models.schemas.shopping.types` | Line items, adjustments, fulfillments, destinations, attribution |
| `ucp_sdk.models.schemas.transports` | REST, MCP, and embedded protocol bindings |
| `ucp_sdk.models.schemas` | Service definitions, capabilities, profile, payment handlers |
Generated Pydantic models and type aliases for the Shopping vertical
(`shopping`, `common`, and `transports` schemas) are lazily re-exported from
both `ucp_sdk.models` and `ucp_sdk.models.schemas`:

| Package | Description |
| ------------------------ | ---------------------------------------------------------------------------------------------------------- |
| `ucp_sdk.models` | Top-level lazy re-export of all generated UCP models (checkout, cart, order, fulfillment, profile, etc.) |
| `ucp_sdk.models.schemas` | Lazy re-export of Pydantic v2 models synthesized from the UCP Shopping, Common, and Transports JSON Schema |

### Validation

All models support Pydantic validation and serialization:

```python
from pydantic import ValidationError
from ucp_sdk.models.schemas.shopping.checkout import Checkout
from ucp_sdk.models import Checkout

# Validate data against UCP schemas
try:
Expand All @@ -110,7 +111,10 @@ except ValidationError as e:

### Prerequisites

This project uses [`uv`](https://docs.astral.sh/uv/) for dependency management.
This project uses [`uv`](https://docs.astral.sh/uv/) for dependency management
and [`ucp-schema`](https://crates.io/crates/ucp-schema)
(`cargo install ucp-schema`) to synthesize the flat `$defs` bundle for model
generation.

### Setup

Expand All @@ -126,26 +130,26 @@ uv sync
### Generating Pydantic Models

The models are automatically generated from the JSON schemas in the UCP
Specification.
Specification using `ucp-schema generate-types` and `datamodel-codegen`.

To regenerate the models:

```bash
uv sync
./generate_models.sh <version>
./generate_models.sh <version-or-path>
```

Where `<version>` is the version of the UCP specification to use (for example,
"2026-08-25").
Where `<version-or-path>` is a UCP release version (for example, `2026-08-25`),
a branch or commit SHA, or a path to a local UCP repository checkout.

If no version is specified, the `main` branch of the
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 formatted using `ruff`.
The generated code is automatically post-processed and formatted using `ruff`.

### Running Tests

Run the schema preprocessing test suite with:
Run the test suite with:

```bash
uv run python -m unittest discover -s tests -p "test_*.py"
Expand Down
158 changes: 110 additions & 48 deletions generate_models.sh
Original file line number Diff line number Diff line change
Expand Up @@ -13,78 +13,140 @@
# See the License for the specific language governing permissions and
# limitations under the License.

# Generate Pydantic models from UCP JSON Schemas
# Generate Pydantic models from UCP JSON Schemas using ucp-schema generate-types.

set -euo pipefail

# Ensure we are in the script's directory
cd "$(dirname "$0")" || exit
cd "$(dirname "$0")"

# Add ~/.local/bin to PATH for uv
export PATH="$HOME/.local/bin:$PATH"

# Check if git is installed
if ! command -v git &> /dev/null; then
echo "Error: git not found. Please install git."
exit 1
fi

# Check if uv is installed before cloning or modifying generated files
if ! command -v uv &> /dev/null; then
echo "Error: uv not found."
echo "Please install uv: curl -LsSf https://astral.sh/uv/install.sh | sh"
exit 1
fi

# UCP Version to use (if provided, use release/$1 branch; otherwise, use main)
if [ -z "$1" ]; then
BRANCH="main"
# Resolve ucp-schema binary
if [ -n "${UCP_SCHEMA_BIN:-}" ] && [ ! -x "$UCP_SCHEMA_BIN" ]; then
echo "Error: UCP_SCHEMA_BIN='$UCP_SCHEMA_BIN' is not executable."
exit 1
elif [ -n "${UCP_SCHEMA_BIN:-}" ]; then
if ! "$UCP_SCHEMA_BIN" generate-types --help &> /dev/null; then
echo "Error: UCP_SCHEMA_BIN='$UCP_SCHEMA_BIN' does not support 'generate-types'."
exit 1
fi
elif command -v ucp-schema &> /dev/null && ucp-schema generate-types --help &> /dev/null; then
UCP_SCHEMA_BIN="$(command -v ucp-schema)"
elif [ -x "../ucp-schema/target/release/ucp-schema" ] && "../ucp-schema/target/release/ucp-schema" generate-types --help &> /dev/null; then
UCP_SCHEMA_BIN="../ucp-schema/target/release/ucp-schema"
elif [ -x "../ucp-schema/target/debug/ucp-schema" ] && "../ucp-schema/target/debug/ucp-schema" generate-types --help &> /dev/null; then
UCP_SCHEMA_BIN="../ucp-schema/target/debug/ucp-schema"
elif [ -f "../ucp-schema/Cargo.toml" ] && command -v cargo &> /dev/null; then
echo "Building ucp-schema from ../ucp-schema..."
cargo build --release --manifest-path "../ucp-schema/Cargo.toml"
UCP_SCHEMA_BIN="../ucp-schema/target/release/ucp-schema"
if ! "$UCP_SCHEMA_BIN" generate-types --help &> /dev/null; then
echo "Error: ../ucp-schema checkout does not support 'generate-types'."
exit 1
fi
else
echo "Error: ucp-schema binary (with generate-types) not found. Set UCP_SCHEMA_BIN, install ucp-schema on PATH, or build ../ucp-schema."
exit 1
fi

TMP_CLONE_DIR=""
SCOPED_SCHEMA_DIR=""
TMP_TYPES_JSON="$(mktemp "${TMPDIR:-/tmp}/ucp-types.XXXXXX.json")"
cleanup() {
rm -f "$TMP_TYPES_JSON"
if [ -n "$SCOPED_SCHEMA_DIR" ] && [ -d "$SCOPED_SCHEMA_DIR" ]; then
rm -rf "$SCOPED_SCHEMA_DIR"
fi
if [ -n "$TMP_CLONE_DIR" ] && [ -d "$TMP_CLONE_DIR" ]; then
rm -rf "$TMP_CLONE_DIR"
fi
}
trap cleanup EXIT

INPUT_ARG="${1:-}"
if [ -n "$INPUT_ARG" ] && [ -d "$INPUT_ARG" ]; then
INPUT_DIR="$INPUT_ARG"
echo "Using local UCP directory: $INPUT_DIR"
elif [[ "$INPUT_ARG" == /* || "$INPUT_ARG" == ./* || "$INPUT_ARG" == ../* ]]; then
echo "Error: Local UCP directory not found at '$INPUT_ARG'."
exit 1
elif [ -z "$INPUT_ARG" ]; then
TMP_CLONE_DIR="$(mktemp -d "${TMPDIR:-/tmp}/ucp-clone.XXXXXX")"
echo "No version specified, cloning main branch..."
git clone -b main --depth 1 https://github.com/Universal-Commerce-Protocol/ucp "$TMP_CLONE_DIR"
INPUT_DIR="$TMP_CLONE_DIR"
elif [[ "$INPUT_ARG" =~ ^[0-9]{4}-[0-9]{2}-[0-9]{2}$ ]]; then
TMP_CLONE_DIR="$(mktemp -d "${TMPDIR:-/tmp}/ucp-clone.XXXXXX")"
BRANCH="release/$INPUT_ARG"
echo "Cloning version $INPUT_ARG (branch: $BRANCH)..."
git clone -b "$BRANCH" --depth 1 https://github.com/Universal-Commerce-Protocol/ucp "$TMP_CLONE_DIR"
INPUT_DIR="$TMP_CLONE_DIR"
elif [[ "$INPUT_ARG" =~ ^[0-9a-fA-F]{7,40}$ ]]; then
TMP_CLONE_DIR="$(mktemp -d "${TMPDIR:-/tmp}/ucp-clone.XXXXXX")"
echo "Cloning UCP repository at commit $INPUT_ARG..."
git clone https://github.com/Universal-Commerce-Protocol/ucp "$TMP_CLONE_DIR"
git -C "$TMP_CLONE_DIR" checkout "$INPUT_ARG"
INPUT_DIR="$TMP_CLONE_DIR"
else
BRANCH="release/$1"
echo "Cloning version $1 (branch: $BRANCH)..."
TMP_CLONE_DIR="$(mktemp -d "${TMPDIR:-/tmp}/ucp-clone.XXXXXX")"
echo "Cloning branch/ref $INPUT_ARG..."
git clone -b "$INPUT_ARG" --depth 1 https://github.com/Universal-Commerce-Protocol/ucp "$TMP_CLONE_DIR"
INPUT_DIR="$TMP_CLONE_DIR"
fi

# Ensure ucp directory is clean before cloning
rm -rf ucp
git clone -b "$BRANCH" --depth 1 https://github.com/Universal-Commerce-Protocol/ucp ucp
rm -rf ucp/.git
if [ -d "$INPUT_DIR/source/schemas" ]; then
SCHEMA_DIR="$INPUT_DIR/source/schemas"
elif [ -d "$INPUT_DIR/spec/schemas" ]; then
SCHEMA_DIR="$INPUT_DIR/spec/schemas"
elif [ -d "$INPUT_DIR/schemas" ]; then
SCHEMA_DIR="$INPUT_DIR/schemas"
elif [ -f "$INPUT_DIR/ucp.json" ]; then
SCHEMA_DIR="$INPUT_DIR"
else
echo "Error: Could not locate schema directory under '$INPUT_DIR'."
exit 1
fi

# Output directory
OUTPUT_DIR="src/ucp_sdk/models/schemas"

# Schema directory (relative to this script)
SCHEMA_DIR="ucp/source/schemas"

# Snapshot the pristine schemas before preprocessing. postprocess_models.py
# reads array contains/minContains/maxContains from these originals because
# preprocessing merges allOf branches and a JSON node holds only one contains,
# so a second contains keyword (e.g. "exactly one total") would otherwise be
# silently dropped before the post-processor could see it.
RAW_SCHEMA_DIR="ucp/raw_schemas"
rm -rf "$RAW_SCHEMA_DIR"
cp -R "$SCHEMA_DIR" "$RAW_SCHEMA_DIR"

echo "Preprocessing schemas..."
uv run python preprocess_schemas.py
# Scope compilation to Shopping + Common + Transports so other verticals do not bleed in.
SCOPED_SCHEMA_DIR="$(mktemp -d "${TMPDIR:-/tmp}/ucp-schemas.XXXXXX")"
if compgen -G "$SCHEMA_DIR/*.json" > /dev/null; then
cp "$SCHEMA_DIR"/*.json "$SCOPED_SCHEMA_DIR"/
fi
for dir in common transports shopping; do
if [ -d "$SCHEMA_DIR/$dir" ]; then
cp -R "$SCHEMA_DIR/$dir" "$SCOPED_SCHEMA_DIR/$dir"
fi
done

echo "Generating Pydantic models from preprocessed schemas..."
echo "Compiling type bundle with ucp-schema generate-types..."
"$UCP_SCHEMA_BIN" generate-types --schema-dir "$SCOPED_SCHEMA_DIR" --output "$TMP_TYPES_JSON"

# Ensure output directory is clean
rm -r -f "$OUTPUT_DIR"
echo "Generating Pydantic models from compiled type bundle..."
rm -rf "$OUTPUT_DIR"
mkdir -p "$OUTPUT_DIR"


# Run generation using uv
# We use --use-schema-description to use descriptions from JSON schema as docstrings
# We use --field-constraints to include validation constraints (regex, min/max, etc.)
# We use --reuse-model to collapse structurally identical generated types.
# Note: Formatting is done as a post-processing step.
uv run \
--link-mode=copy \
--extra-index-url https://pypi.org/simple python \
-m datamodel_code_generator \
--input "$SCHEMA_DIR" \
--input "$TMP_TYPES_JSON" \
--input-file-type jsonschema \
--output "$OUTPUT_DIR" \
--output "$OUTPUT_DIR/_generated.py" \
--output-model-type pydantic_v2.BaseModel \
--use-schema-description \
--field-constraints \
Expand All @@ -94,27 +156,27 @@ uv run \
--use-double-quotes \
--extra-fields=allow \
--use-type-alias \
--use-standard-collections \
--reuse-model \
--custom-template-dir templates \
--use-title-as-name \
--additional-imports pydantic.ConfigDict


echo "Post-processing generated models (constraints the generator ignores)..."
uv run python postprocess_models.py || exit 1
uv run python postprocess_models.py "$TMP_TYPES_JSON" src/ucp_sdk/models "$SCOPED_SCHEMA_DIR"

echo "Formatting generated models..."
uv run ruff check --fix src/ucp_sdk/models
uv run ruff format src/ucp_sdk/models

# Normalize file endings (as pre-commit's end-of-file-fixer does)
python3 - <<'PY'
from pathlib import Path

for path in Path("src/ucp_sdk/models/schemas").rglob("*.py"):
for path in Path("src/ucp_sdk/models").rglob("*.py"):
text = path.read_text(encoding="utf-8")
fixed = text.rstrip("\n") + "\n" if text.strip() else ""
if fixed != text:
path.write_text(fixed, encoding="utf-8")
PY

echo "Formatting generated models..."
uv run ruff format
uv run ruff check --fix "$OUTPUT_DIR"

echo "Done. Models generated in $OUTPUT_DIR"
echo "Done. Models generated in $OUTPUT_DIR/_generated.py"
Loading
Loading