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
21 changes: 21 additions & 0 deletions .github/PULL_REQUEST_TEMPLATE.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
## What does this PR change?

<!-- Describe the change. -->

## Why?

<!-- Explain the motivation. -->

## Testing

<!-- Explain how this was tested. -->

## Checklist

- [ ] I have read `CONTRIBUTING.md`.
- [ ] Every commit in this PR includes a valid DCO `Signed-off-by` line.
- [ ] I have the right to submit all material in this PR.
- [ ] I have not included secrets or confidential data.
- [ ] I have updated tests where appropriate.
- [ ] I have updated documentation where appropriate.
- [ ] I have preserved required third-party licenses and attributions.
97 changes: 97 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
name: CI

# The default-branch ruleset of Kaiten's SDK repositories requires three checks, named `lint`,
# `build` and `test`. The jobs below report exactly those names; the per-version test jobs
# report under their own names, and `test` answers for all of them.

on:
push:
branches: ["main"]
pull_request:
branches: ["main"]

permissions:
contents: read

jobs:
lint:
name: lint
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5

- uses: astral-sh/setup-uv@v6
with:
enable-cache: true

- run: uv sync --locked

# The models come from openapi/ and the sync client from the async one. A commit that
# edits either source without regenerating would ship code the repository cannot
# reproduce, so the check regenerates and compares.
- name: Generated code is up to date
run: |
uv run python scripts/generate_models.py --check
uv run python scripts/unasync.py --check

- run: uv run ruff check .
- run: uv run ruff format --check .
- run: uv run mypy

tests:
name: test (Python ${{ matrix.python-version }})
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
python-version: ["3.10", "3.11", "3.12", "3.13", "3.14"]
steps:
- uses: actions/checkout@v5

- uses: astral-sh/setup-uv@v6
with:
enable-cache: true
python-version: ${{ matrix.python-version }}

- run: uv sync --locked
- run: uv run pytest --cov --cov-report=term-missing

lowest-dependencies:
name: test (lowest dependencies)
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5

- uses: astral-sh/setup-uv@v6
with:
python-version: "3.10"

# The lower bounds in pyproject.toml are a promise to every consumer; this is what keeps
# them honest.
- run: uv sync --resolution lowest-direct
- run: uv run pytest

# The one `test` check the ruleset requires, standing for every test job above. It runs even
# when one of them failed, and passes only when all of them succeeded: a required check that
# is skipped counts as passing, so a plain `needs` would let a failure through.
test:
name: test
if: always()
needs: [tests, lowest-dependencies]
runs-on: ubuntu-latest
steps:
- name: Every test job passed
env:
RESULTS: ${{ toJSON(needs.*.result) }}
run: |
echo "test jobs: $RESULTS"
jq -e 'length > 0 and all(.[]; . == "success")' <<< "$RESULTS" > /dev/null

build:
name: build
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
- uses: astral-sh/setup-uv@v6
- run: uv build
- run: uvx twine check --strict dist/*
113 changes: 113 additions & 0 deletions .github/workflows/dco.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
name: DCO

# Every commit of a pull request must be signed off by its author, as DCO.md and
# CONTRIBUTING.md ask: a `Signed-off-by: Name <email>` trailer naming the commit's author.
# Require the `DCO` check on main; this workflow is the source of truth.
#
# pull_request_target runs the workflow as it is on main, never as a pull request edits it,
# so a contributor cannot change the check that judges their own commits. It is safe because
# nothing here checks out or runs the pull request's code: the check below reads the commits'
# metadata from the API, and it needs no other file, so it can be copied to any repository.
on:
pull_request_target:
branches: ["main"]

permissions:
contents: read
pull-requests: read

jobs:
dco:
name: DCO
runs-on: ubuntu-latest
timeout-minutes: 5
steps:
- name: Every commit is signed off by its author
shell: python
env:
GITHUB_TOKEN: ${{ github.token }}
PR_NUMBER: ${{ github.event.pull_request.number }}
run: |
"""Fail when a commit of the pull request is not signed off by its author (DCO 1.1)."""

import json
import os
import re
import sys
import urllib.request

SIGN_OFF = re.compile(
r"^Signed-off-by:[ \t]*(?P<name>.+?)[ \t]*<(?P<email>[^<>]+)>[ \t]*$", re.M
)
FIX = (
"Sign commits off with `git commit -s`. To fix this pull request:\n\n"
" git commit --amend --signoff --no-edit # the latest commit\n"
" git rebase --signoff origin/main # every commit of the branch\n"
" git push --force-with-lease\n\n"
"See CONTRIBUTING.md and DCO.md."
)


def problem(commit):
"""Why a commit, as the API lists a pull request's commits, fails; None if it passes.

A commit passes when one of its Signed-off-by trailers names its author, by name and
by email, ignoring case. A merge commit adds no work of its own, and a bot account
certifies nothing, so neither is checked.
"""
if len(commit.get("parents") or []) > 1:
return None
if (commit.get("author") or {}).get("type") == "Bot":
return None
author = commit["commit"]["author"]
signoffs = [(m["name"], m["email"]) for m in SIGN_OFF.finditer(commit["commit"]["message"])]
if any(
name.lower() == author["name"].lower() and email.lower() == author["email"].lower()
for name, email in signoffs
):
return None
expected = f"{author['name']} <{author['email']}>"
if not signoffs:
return f"no Signed-off-by; expected {expected}"
found = ", ".join(f"{name} <{email}>" for name, email in signoffs)
return f"signed off by {found}, not by its author: expected {expected}"


def commits():
"""Every commit of the pull request, page by page."""
api = os.environ.get("GITHUB_API_URL", "https://api.github.com")
repository, number = os.environ["GITHUB_REPOSITORY"], os.environ["PR_NUMBER"]
page = 1
while True:
request = urllib.request.Request(
f"{api}/repos/{repository}/pulls/{number}/commits?per_page=100&page={page}",
headers={
"Accept": "application/vnd.github+json",
"Authorization": f"Bearer {os.environ['GITHUB_TOKEN']}",
"X-GitHub-Api-Version": "2022-11-28",
},
)
with urllib.request.urlopen(request, timeout=30) as response:
batch = json.load(response)
yield from batch
if len(batch) < 100:
return
page += 1


def main():
listed = list(commits())
failed = [(commit, why) for commit in listed if (why := problem(commit)) is not None]
for commit, why in failed:
subject = commit["commit"]["message"].splitlines()[0]
print(f"::error title=DCO::{commit['sha']} {subject}: {why}")
if failed:
print(f"\n{len(failed)} of {len(listed)} commits are not signed off by their author.")
print(f"\n{FIX}")
return 1
print(f"All {len(listed)} commits are signed off by their author.")
return 0


if __name__ == "__main__":
sys.exit(main())
32 changes: 32 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
name: Release

on:
push:
tags: ["v*"]

jobs:
publish:
name: Publish to PyPI
runs-on: ubuntu-latest
environment: pypi
permissions:
contents: read
id-token: write # PyPI trusted publishing: no API token is stored anywhere.
steps:
- uses: actions/checkout@v5
- uses: astral-sh/setup-uv@v6

- name: The tag names the package version
run: |
version=$(sed -n 's/^__version__ = "\(.*\)"$/\1/p' src/kaitencloud/_version.py)
if [ "v$version" != "$GITHUB_REF_NAME" ]; then
echo "tag $GITHUB_REF_NAME does not match the package version $version" >&2
exit 1
fi

- run: uv sync --locked
- run: uv run pytest

- run: uv build
- run: uvx twine check --strict dist/*
- run: uv publish --trusted-publishing always
21 changes: 21 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
# Python
__pycache__/
*.py[cod]
*.egg-info/
build/
dist/

# Tooling
.venv/
.pytest_cache/
.mypy_cache/
.ruff_cache/
.coverage
.coverage.*
coverage.xml
htmlcov/

# What a playbook run recorded about itself
.kaiten-playbook.json

.DS_Store
53 changes: 53 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
# Changelog

All notable changes to this project are documented here. The format follows
[Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and the project adheres to
[Semantic Versioning](https://semver.org/spec/v2.0.0.html): until 1.0.0, a minor version may
change the public API, and a patch version never does.

## [0.1.0] - Unreleased

The first release of the Kaiten Python SDK, generated and tested against the contract of
[`kaitencloud/kaiten@fa7f554f`](https://github.com/kaitencloud/kaiten/commit/fa7f554fb6464f52c2a3f1ed487f4c7383c8125d).

### Added

- `KaitenClient` and `AsyncKaitenClient` for the Core API: customers, instances, licenses and
license families, entitlements and entitlement groups, usage reporting, feature flag
definitions, deployment zones, releases, components, metadata fields, service accounts,
connectors and integrations -- 98 of the contract's 107 operations. Of the nine left out,
three are the console's rule-editor tooling and four a signed-in person's notification feed
and preferences, which Kaiten's own SDK coverage excludes too; the other two are OFREP flag
evaluation, which belongs to OpenFeature (see below).
- `KaitenPlatformClient` and `AsyncKaitenPlatformClient` for the Platform API: all 10 operations.
- Configuration from arguments or from `KAITEN_BASE_URL` and `KAITEN_AUTH_TOKEN`. There is no
default API address: every deployment has its own, and the SDK never guesses where to send
a token.
- License versioning: `licenses.create()` opens a license family or adds its next version
(`family_slug`, `family_id`, `lifecycle_state`), `licenses.publish()`, `archive()` and
`unarchive()` move a version through its lifecycle, and `license_families.list()` and
`get()` resolve a family to the version it currently serves, or to a pinned one.
- Models for every response and every webhook event, generated from the contract with
pydantic v2: snake_case attributes, lenient about fields and enum values added after a
release, and `to_dict()` for the exact wire shape.
- `kaitencloud.types.Scope`, every scope an organization token can carry, generated from the
contract and used to type `service_accounts.create_token()` and `platform.tokens.mint()`.
- List methods that walk every page, and fail with `PaginationError` rather than return a list
the API said was incomplete.
- Automatic retries with exponential backoff for requests that are safe to repeat -- never a
usage report -- honouring `Retry-After`.
- Typed errors for every failure, carrying the RFC 9457 problem: `code`, `detail`, `error_id`.
`ThresholdExceededError` for a usage report refused at the license's cap.
- A credential guard that refuses a platform credential on the Core client, an organization
token on the platform client, and a webhook secret on either, before anything is sent.
- `kaitencloud.webhooks`: Svix signature verification, and a typed model for each of the 55
published events.
- `kaitencloud.targeting`, builders for feature flag variants, rollouts and targeting rules.
- `kaitencloud.usage`, the enforcement arithmetic the API applies to a grant and its overage
allowance.
- `examples/playbook.py`, a resumable walk through a deployment of your own, step by step.

### Not included, on purpose

- Flag evaluation. Kaiten implements OFREP, so flags are evaluated with an OpenFeature SDK and
its generic OFREP provider (`openfeature-provider-ofrep`); the README shows how.
Loading
Loading