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
7 changes: 5 additions & 2 deletions .config/mise/tasks/build
Original file line number Diff line number Diff line change
@@ -1,8 +1,11 @@
#!/usr/bin/env bash
#MISE description="Build the bundled morphir-python distribution"
#MISE description="Build the independent library and CLI distributions"
set -euo pipefail

echo "Building morphir library and SDK..."
uv build packages/morphir --out-dir packages/morphir/dist

echo "Building morphir-python package..."
uv build --out-dir dist
uv build packages/morphir-tools --out-dir packages/morphir-tools/dist

echo "Build complete!"
1 change: 1 addition & 0 deletions .config/mise/tasks/clean
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ set -euo pipefail

echo "Cleaning build artifacts..."
rm -rf build/ dist/ .eggs/
rm -rf packages/morphir/dist/ packages/morphir-tools/dist/
find . -type d -name "*.egg-info" -exec rm -rf {} + 2>/dev/null || true
find . -type d -name "__pycache__" -exec rm -rf {} + 2>/dev/null || true
find . -type f -name "*.py[cod]" -delete 2>/dev/null || true
Expand Down
169 changes: 83 additions & 86 deletions .config/mise/tasks/release-check
Original file line number Diff line number Diff line change
@@ -1,105 +1,102 @@
#!/usr/bin/env bash
#MISE description="Check built distributions and scan runtime dependencies"
#MISE description="Qualify separate library and CLI distributions for release"
set -euo pipefail

# Keep the candidate environment separate from development and scanner dependencies.
release_dir="$(pwd)/.dev/release-check"
mkdir -p "$release_dir"

uv lock --check
python - <<'PY'
import os
import tarfile
import tomllib
import zipfile
from email.parser import BytesParser
from pathlib import Path

for folder, name in ((".", "morphir-python"),):
directory = Path(folder)
project = tomllib.loads((directory / "pyproject.toml").read_text())["project"]
tag = os.environ.get("RELEASE_TAG", "")
if tag and tag not in (f"v{project['version']}", f"python-v{project['version']}"):
raise SystemExit(f"{tag} does not match {name} {project['version']}")
stem = name.replace("-", "_") + "-" + project["version"]
expected = {f"{stem}-py3-none-any.whl", f"{stem}.tar.gz"}
files = {p.name for p in (directory / "dist").iterdir()
if p.is_file() and p.name != ".gitignore"}
if files != expected:
raise SystemExit(f"Unexpected distributions for {name}: {files}")
for filename in expected:
path = directory / "dist" / filename
if path.suffix == ".whl":
with zipfile.ZipFile(path) as archive:
metadata = [n for n in archive.namelist() if n.endswith(".dist-info/METADATA")]
raw = archive.read(metadata[0])
has_license = any(n.endswith("/licenses/LICENSE") for n in archive.namelist())
for source in (Path("packages/morphir/src"), Path("packages/morphir-tools/src")):
expected_sources = {str(p.relative_to(source)) for p in source.rglob("*")
if p.is_file() and p.suffix in (".py", ".typed")}
missing = expected_sources - set(archive.namelist())
if missing:
raise SystemExit(f"Missing bundled sources in {path}: {missing}")
else:
with tarfile.open(path) as archive:
member = archive.extractfile(f"{stem}/PKG-INFO")
if member is None:
raise SystemExit(f"Missing metadata in {path}")
raw = member.read()
has_license = f"{stem}/LICENSE" in archive.getnames()
metadata = BytesParser().parsebytes(raw)
if metadata["Name"] != name or metadata["Version"] != project["version"]:
raise SystemExit(f"Incorrect name or version in {path}")
if metadata["License-Expression"] != "Apache-2.0" or not has_license:
raise SystemExit(f"Missing Apache-2.0 license in {path}")
if any(req.startswith("morphir") for req in metadata.get_all("Requires-Dist", [])):
raise SystemExit(f"{path} must not depend on a separate Morphir distribution")
PY

python scripts/check_distributions.py
uvx --from twine==7.0.0 twine check --strict \
dist/*

packages/morphir/dist/* packages/morphir-tools/dist/*
uv export --frozen --all-packages --no-default-groups --no-emit-workspace \
--no-hashes --output-file "$release_dir/constraints.txt"
uv venv --clear --python 3.14 "$release_dir/venv"
release_python="$release_dir/venv/bin/python"
uv pip install --python "$release_python" --constraint "$release_dir/constraints.txt" \
dist/*.whl
uv pip check --python "$release_python"

"$release_python" -I - <<'PY'
from importlib.metadata import PackageNotFoundError, version
packages=(morphir morphir-python)
if [[ ${RELEASE_TAG:-} == morphir-v* ]]; then
packages=(morphir)
elif [[ ${RELEASE_TAG:-} == python-v* ]]; then
packages=(morphir-python)
fi

for package in "${packages[@]}"; do
uv venv --clear --python 3.14 "$release_dir/$package-venv"
release_python="$release_dir/$package-venv/bin/python"
if [[ "$package" == morphir ]]; then
uv pip install --python "$release_python" packages/morphir/dist/*.whl
"$release_python" -I - <<'PY_CORE'
from importlib.metadata import distribution
from importlib.util import find_spec

import morphir
import morphir_tools
from morphir.ir import format_version
from morphir.sdk import basics

assert morphir.__version__ == version("morphir-python")
assert morphir_tools.__version__ == version("morphir-python")
core = distribution("morphir")
assert morphir.__version__ == core.version
assert not core.requires
assert not core.entry_points
assert basics.round(2.5) == 3
assert format_version.V3.major == 3
try:
version("morphir")
except PackageNotFoundError:
pass
else:
raise AssertionError("Bundle verification must not install a separate morphir wheel")
PY
"$release_dir/venv/bin/morphir-python" --help
"$release_dir/venv/bin/morphir-python" ir inspect tests/fixtures/ir/v3/elm-simpleValueTree.json
assert find_spec("morphir_tools") is None
assert find_spec("typer") is None
PY_CORE
else
uv pip install --python "$release_python" --constraint "$release_dir/constraints.txt" \
packages/morphir/dist/*.whl packages/morphir-tools/dist/*.whl
"$release_python" -I - <<'PY_CLI'
from importlib.metadata import distribution, version

import morphir
import morphir_tools

assert morphir.__version__ == version("morphir")
assert morphir_tools.__version__ == version("morphir-python")
core_files = set(distribution("morphir").files or [])
cli_files = set(distribution("morphir-python").files or [])
assert not core_files.intersection(cli_files)
PY_CLI
"$release_dir/$package-venv/bin/morphir-python" ir inspect \
tests/fixtures/ir/v3/elm-simpleValueTree.json
fi
uv pip check --python "$release_python"
uv pip freeze --python "$release_python" --exclude morphir --exclude morphir-python \
> "$release_dir/$package-requirements.txt"
if [[ -s "$release_dir/$package-requirements.txt" ]]; then
uvx --from pip-audit==2.10.1 pip-audit --strict --no-deps --disable-pip \
--requirement "$release_dir/$package-requirements.txt" \
--format json --output "$release_dir/$package-vulnerabilities.json"
else
echo '{"dependencies": [], "fixes": []}' > "$release_dir/$package-vulnerabilities.json"
echo "$package has no third-party runtime dependencies to audit"
fi
uvx --from pip-licenses==5.5.0 pip-licenses --python "$release_python" \
--from=mixed \
--allow-only='Apache-2.0;Apache Software License;MIT;MIT License;BSD-2-Clause;BSD-3-Clause;BSD License;ISC;ISC License;ISC License (ISCL)' \
--format=json --output-file "$release_dir/$package-licenses.json"
done

# Audit the exact installed third-party versions without querying PyPI for the
# first-party bundle being released.
uv pip freeze --python "$release_python" --exclude morphir-python \
> "$release_dir/requirements.txt"
uvx --from pip-audit==2.10.1 pip-audit --strict --no-deps --disable-pip \
--requirement "$release_dir/requirements.txt" \
--format json --output "$release_dir/vulnerabilities.json"
# Exercise the supported migration from the old distribution, which owned both
# namespaces. Uninstall old owners before installing the split distributions.
if [[ ${RELEASE_TAG:-} == morphir-v* ]]; then
exit 0
fi
uv venv --clear --python 3.14 "$release_dir/migration-venv"
migration_python="$release_dir/migration-venv/bin/python"
uv pip install --python "$migration_python" --constraint "$release_dir/constraints.txt" \
'morphir-python==0.0.1'
uv pip uninstall --python "$migration_python" morphir-python morphir
uv pip install --python "$migration_python" --constraint "$release_dir/constraints.txt" \
packages/morphir/dist/*.whl packages/morphir-tools/dist/*.whl
"$migration_python" -I - <<'PY_MIGRATION'
from importlib.metadata import version

# An exact allowlist rejects unknown licenses and newly introduced copyleft or
# proprietary licenses. Changes to this policy require maintainer review.
uvx --from pip-licenses==5.5.0 pip-licenses --python "$release_python" \
--from=mixed \
--allow-only='Apache-2.0;Apache Software License;MIT;MIT License;BSD-2-Clause;BSD-3-Clause;BSD License;ISC;ISC License;ISC License (ISCL)' \
--format=json --output-file "$release_dir/licenses.json"
import morphir
import morphir_tools
from morphir.sdk import basics

assert morphir.__version__ == version("morphir")
assert morphir_tools.__version__ == version("morphir-python")
assert basics.round(2.5) == 3
PY_MIGRATION
"$release_dir/migration-venv/bin/morphir-python" --help
uv pip check --python "$migration_python"
2 changes: 1 addition & 1 deletion .config/mise/tasks/typecheck
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
set -euo pipefail

echo "Running mypy..."
uv run mypy packages/morphir/src packages/morphir-tools/src
uv run mypy packages/morphir/src packages/morphir-tools/src scripts

echo "Running pyright..."
uv run pyright packages/morphir/src packages/morphir-tools/src
11 changes: 9 additions & 2 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ on:
workflow_call:
inputs:
release-tag:
description: Tag that must match the bundled package version
description: Package-specific tag that must match its project version
type: string
default: ''

Expand Down Expand Up @@ -97,11 +97,18 @@ jobs:
env:
RELEASE_TAG: ${{ inputs.release-tag }}

- name: Upload morphir distributions
uses: actions/upload-artifact@v6
with:
name: morphir-dist
path: packages/morphir/dist/
if-no-files-found: error

- name: Upload morphir-python distributions
uses: actions/upload-artifact@v6
with:
name: morphir-python-dist
path: dist/
path: packages/morphir-tools/dist/
if-no-files-found: error

- name: Upload release scan reports
Expand Down
41 changes: 36 additions & 5 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ name: Release
on:
push:
tags:
- 'v*'
- 'morphir-v*'
- 'python-v*'
# Qualification only. Publishing requires a tag push.
workflow_dispatch:
Expand All @@ -22,9 +22,35 @@ jobs:
with:
release-tag: ${{ github.ref_type == 'tag' && github.ref_name || '' }}

publish-morphir:
name: Publish morphir library and SDK to PyPI
if: >-
github.event_name == 'push' &&
github.repository == 'finos/morphir-python' &&
startsWith(github.ref, 'refs/tags/morphir-v')
needs: qualify
runs-on: ubuntu-latest
permissions:
id-token: write
environment:
name: pypi
url: https://pypi.org/project/morphir/
steps:
- name: Download qualified morphir distributions
uses: actions/download-artifact@v7
with:
name: morphir-dist
path: dist/

- name: Publish to PyPI
uses: pypa/gh-action-pypi-publish@release/v1

publish-morphir-python:
name: Publish morphir-python to PyPI
if: github.event_name == 'push' && github.repository == 'finos/morphir-python'
if: >-
github.event_name == 'push' &&
github.repository == 'finos/morphir-python' &&
startsWith(github.ref, 'refs/tags/python-v')
needs: qualify
runs-on: ubuntu-latest
permissions:
Expand All @@ -44,18 +70,23 @@ jobs:

github-release:
name: Create GitHub Release
needs: publish-morphir-python
needs: [publish-morphir, publish-morphir-python]
if: >-
always() && !cancelled() &&
(needs.publish-morphir.result == 'success' ||
needs.publish-morphir-python.result == 'success') &&
!contains(needs.*.result, 'failure')
runs-on: ubuntu-latest
permissions:
contents: write
steps:
- name: Checkout repository
uses: actions/checkout@v6

- name: Download all artifacts
- name: Download the published package artifacts
uses: actions/download-artifact@v7
with:
pattern: '*-dist'
name: ${{ startsWith(github.ref, 'refs/tags/morphir-v') && 'morphir-dist' || 'morphir-python-dist' }}
path: dist/
merge-multiple: true

Expand Down
12 changes: 7 additions & 5 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ Morphir Python is a port of Morphir to Python, providing functional domain model
morphir-python/
├── packages/
│ ├── morphir/ # Core library workspace - IR models, types, pure functions
│ └── morphir-tools/ # CLI source; root morphir-python bundles library and CLI
│ └── morphir-tools/ # morphir-python CLI distribution (depends on morphir)
├── tests/
│ ├── unit/ # Unit tests for morphir package
│ └── tools/ # Unit tests for morphir-python package
Expand All @@ -21,10 +21,12 @@ morphir-python/

### Package Responsibilities

The root `pyproject.toml` publishes one `morphir-python` distribution containing
both source components. It provides `import morphir`, `import morphir_tools`, and
the `morphir-python` command. The core remains a standalone workspace for development;
the release workflow does not publish or require a separate `morphir` PyPI package.
The root `pyproject.toml` is a development workspace. `packages/morphir/` publishes
`morphir`, containing the library and SDK without third-party runtime dependencies.
`packages/morphir-tools/` publishes `morphir-python`, containing the CLI and depending
on `morphir`. They own separate import namespaces and have independent versions and
release tags (`morphir-v*` and `python-v*`). The CLI is the future integration surface
for MEP extension-protocol commands and potential WASM build tooling.

**`morphir` (core library)**
- Morphir IR (Intermediate Representation) models, in two versions:
Expand Down
Loading
Loading