Skip to main content

strapi-kit

CI codecov PyPI version Python versions License

A modern Python client for Strapi CMS with comprehensive import/export capabilities.

Features

  • 🚀 Full Strapi Support: Works with both v4 and v5 APIs with automatic version detection
  • Async & Sync: Choose between synchronous and asynchronous clients based on your needs
  • 🔒 Type Safe: Built with Pydantic for robust data validation and type safety
  • 🔄 Import/Export: Comprehensive backup/restore and data migration tools
  • 🔁 Smart Retry: Automatic retry with exponential backoff for transient failures
  • 🔍 Schema Introspection: Content-Type Builder API support for schema discovery
  • 📝 Blocks ↔ Markdown: Convert Strapi v5 blocks rich text JSON to and from Markdown
  • 📦 Modern Python: Built for Python 3.12+ with full type hints

Installation

pip install strapi-kit

Or with uv (recommended for faster installs):

uv pip install strapi-kit

For development:

# With pip
pip install -e ".[dev]"

# With uv (recommended)
uv pip install -e ".[dev]"

Quick Start

Type-Safe API (Recommended)

The typed API provides full type safety, IDE autocomplete, and automatic v4/v5 normalization:

from strapi_kit import SyncClient, StrapiConfig
from strapi_kit.models import StrapiQuery, FilterBuilder, SortDirection

config = StrapiConfig(
    base_url="http://localhost:1337",
    api_token="your-api-token"
)

with SyncClient(config) as client:
    # Build a type-safe query
    query = (StrapiQuery()
        .filter(FilterBuilder()
            .eq("status", "published")
            .gt("views", 100))
        .sort_by("publishedAt", SortDirection.DESC)
        .paginate(page=1, page_size=25)
        .populate_fields(["author", "category"]))

    # Get normalized, type-safe response
    response = client.get_many("articles", query=query)

    # Works with both v4 and v5 automatically!
    for article in response.data:
        print(f"{article.id}: {article.attributes['title']}")
        print(f"Published: {article.published_at}")

Raw API (Backward Compatible)

The raw API returns dictionaries directly from Strapi:

from strapi_kit import SyncClient, StrapiConfig

config = StrapiConfig(
    base_url="http://localhost:1337",
    api_token="your-api-token"
)

with SyncClient(config) as client:
    # Get raw JSON response
    response = client.get("articles")
    print(response)  # dict

    # Origin-rooted admin probe (no /api prefix)
    info = client.get_admin_information()
    print(info.strapi_version)  # e.g. "5.11.0" or None

Asynchronous Usage

Both typed and raw APIs work with async:

import asyncio
from strapi_kit import AsyncClient, StrapiConfig
from strapi_kit.models import StrapiQuery, FilterBuilder

async def main():
    config = StrapiConfig(
        base_url="http://localhost:1337",
        api_token="your-api-token"
    )

    async with AsyncClient(config) as client:
        # Typed API
        query = StrapiQuery().filter(FilterBuilder().eq("status", "published"))
        response = await client.get_many("articles", query=query)

        for article in response.data:
            print(article.attributes["title"])

asyncio.run(main())

Configuration

strapi-kit provides flexible configuration options through dependency injection:

1. Using .env Files (Recommended for Development)

Create a .env file in your project root:

# .env
STRAPI_BASE_URL=http://localhost:1337
STRAPI_API_TOKEN=your-api-token-here
STRAPI_TIMEOUT=30.0
STRAPI_MAX_CONNECTIONS=10
STRAPI_RETRY_MAX_ATTEMPTS=3

Then load it automatically:

from strapi_kit import load_config, SyncClient

# Automatically searches for .env, .env.local, or ~/.config/strapi/.env
config = load_config()

with SyncClient(config) as client:
    response = client.get("articles")

2. Using Environment Variables (Recommended for Production)

Perfect for containerized deployments (Docker, Kubernetes):

export STRAPI_BASE_URL=https://api.production.com
export STRAPI_API_TOKEN=production-secret-token
export STRAPI_TIMEOUT=120.0
export STRAPI_MAX_CONNECTIONS=100
from strapi_kit import ConfigFactory, SyncClient

# Load from environment variables only (no .env files)
config = ConfigFactory.from_environment_only()

with SyncClient(config) as client:
    response = client.get("articles")

3. Explicit Configuration (Recommended for Testing)

Create configuration programmatically:

from strapi_kit import create_config, SyncClient

config = create_config(
    base_url="http://localhost:1337",
    api_token="your-token",
    timeout=60.0,
    max_connections=50,
    verify_ssl=True
)

with SyncClient(config) as client:
    response = client.get("articles")

4. Advanced Configuration Patterns

Custom .env File Location

from strapi_kit import ConfigFactory

# Load from specific file
config = ConfigFactory.from_env_file("/path/to/custom.env")

# Search multiple locations
config = ConfigFactory.from_env(
    search_paths=[
        ".env.local",      # Local overrides (highest priority)
        ".env",            # Base config
        "~/.strapi/.env"   # User config (lowest priority)
    ]
)

Layered Configuration (Development → Production)

from strapi_kit import ConfigFactory

# Base configuration from .env file
base_config = ConfigFactory.from_env_file(".env")

# Override specific values for production
production_overrides = ConfigFactory.from_dict({
    "base_url": "https://api.production.com",
    "api_token": "production-token",
    "timeout": 120.0,
    "max_connections": 100
})

# Merge configs (later configs override earlier ones)
final_config = ConfigFactory.merge(base_config, production_overrides)

Retry Configuration

Configure automatic retry behavior:

from strapi_kit import StrapiConfig, RetryConfig

config = StrapiConfig(
    base_url="http://localhost:1337",
    api_token="your-token",
    retry=RetryConfig(
        max_attempts=5,           # Retry up to 5 times
        initial_wait=2.0,         # Wait 2 seconds before first retry
        max_wait=120.0,          # Maximum 2 minutes between retries
        exponential_base=3.0,    # Faster backoff growth
        retry_on_status={500, 502, 503, 504, 408}  # Retry on these status codes
    )
)

Or via environment variables:

STRAPI_RETRY_MAX_ATTEMPTS=5
STRAPI_RETRY_INITIAL_WAIT=2.0
STRAPI_RETRY_MAX_WAIT=120.0
STRAPI_RETRY_EXPONENTIAL_BASE=3.0

Configuration Reference

All available options:

Option Type Default Description
base_url str Required Strapi instance URL
api_token str Required API authentication token
api_version "v4" | "v5" | "auto" "auto" API version (auto-detect or explicit)
timeout float 30.0 Request timeout in seconds
max_connections int 10 Maximum concurrent connections
verify_ssl bool True Verify SSL certificates
rate_limit_per_second float | None None Rate limiting (None = unlimited)
retry.max_attempts int 3 Maximum retry attempts (1-10)
retry.initial_wait float 1.0 Initial retry wait time (seconds)
retry.max_wait float 60.0 Maximum retry wait time (seconds)
retry.exponential_base float 2.0 Exponential backoff multiplier

Usage Examples

Filtering

Use the FilterBuilder to create complex filters with 24 operators:

from strapi_kit.models import StrapiQuery, FilterBuilder

# Simple equality
query = StrapiQuery().filter(FilterBuilder().eq("status", "published"))

# Comparison operators
query = StrapiQuery().filter(
    FilterBuilder()
        .gt("views", 100)
        .lte("price", 50)
)

# String matching
query = StrapiQuery().filter(
    FilterBuilder()
        .contains("title", "Python")
        .starts_with("slug", "blog-")
)

# Array operators
query = StrapiQuery().filter(
    FilterBuilder().in_("category", ["tech", "science"])
)

# Logical operators (AND, OR, NOT)
query = StrapiQuery().filter(
    FilterBuilder()
        .eq("status", "published")
        .or_group(
            FilterBuilder().gt("views", 1000),
            FilterBuilder().gt("likes", 500)
        )
)

# Deep relation filtering
query = StrapiQuery().filter(
    FilterBuilder()
        .eq("author.name", "John Doe")
        .eq("author.country", "USA")
)

Sorting

Sort by one or multiple fields:

from strapi_kit.models import StrapiQuery, SortDirection

# Single field
query = StrapiQuery().sort_by("publishedAt", SortDirection.DESC)

# Multiple fields
query = (StrapiQuery()
    .sort_by("status", SortDirection.ASC)
    .then_sort_by("publishedAt", SortDirection.DESC)
    .then_sort_by("title", SortDirection.ASC))

# Sort by relation field
query = StrapiQuery().sort_by("author.name", SortDirection.ASC)

Pagination

Choose between page-based or offset-based pagination:

from strapi_kit.models import StrapiQuery

# Page-based pagination
query = StrapiQuery().paginate(page=1, page_size=25)

# Offset-based pagination
query = StrapiQuery().paginate(start=0, limit=50)

# Disable count for performance
query = StrapiQuery().paginate(page=1, page_size=100, with_count=False)

Stock Strapi silently caps pagination[pageSize] at the server maxLimit (default 100). Requesting page_size > 100 is unsafe unless that limit is raised. PagePagination stays capped at 100; get_many() does not verify the echo. For import/export (or any completeness-sensitive read), check the echo:

from strapi_kit import assert_pagination_echo

response = client.get_many("articles", query)
total = assert_pagination_echo(
    response.meta,
    requested_page=1,
    requested_page_size=25,
)

Population (Relations)

Expand relations, components, and dynamic zones:

from strapi_kit.models import StrapiQuery, Populate, FilterBuilder, SortDirection

# Populate all relations
query = StrapiQuery().populate_all()

# Populate specific fields
query = StrapiQuery().populate_fields(["author", "category", "tags"])

# Advanced population with filtering and field selection
query = StrapiQuery().populate(
    Populate()
        .add_field("author", fields=["name", "email", "avatar"])
        .add_field("category")
        .add_field("comments",
            filters=FilterBuilder().eq("approved", True),
            sort=Sort().by_field("createdAt", SortDirection.DESC),
            fields=["content", "author"])
)

# Nested population
query = StrapiQuery().populate(
    Populate().add_field(
        "author",
        nested=Populate().add_field("profile")
    )
)

Field Selection

Select specific fields to reduce payload size:

from strapi_kit.models import StrapiQuery

query = StrapiQuery().select(["title", "description", "publishedAt"])

Locale & Publication State

For i18n and draft/publish workflows:

from strapi_kit.models import StrapiQuery, PublicationState

# Set locale
query = StrapiQuery().with_locale("fr")

# Set publication state (Strapi v4)
query = StrapiQuery().with_publication_state(PublicationState.LIVE)

# Set document status (Strapi v5 Draft & Publish). Omitted status
# defaults to published and hides drafts.
from strapi_kit import DocumentStatus, PublicationFilter
query = StrapiQuery().with_document_status(DocumentStatus.DRAFT)

# Filter by how draft and published versions relate (never published,
# modified since last publish, …). Combines with status=.
query = (StrapiQuery()
    .with_document_status(DocumentStatus.DRAFT)
    .with_publication_filter(PublicationFilter.NEVER_PUBLISHED))

Complete Example

Combine all features for complex queries:

from strapi_kit import SyncClient, StrapiConfig
from strapi_kit.models import (
    StrapiQuery,
    FilterBuilder,
    SortDirection,
    Populate,
    PublicationState,
)

config = StrapiConfig(
    base_url="http://localhost:1337",
    api_token="your-token"
)

with SyncClient(config) as client:
    # Build complex query
    query = (StrapiQuery()
        # Filters
        .filter(FilterBuilder()
            .eq("status", "published")
            .gte("publishedAt", "2024-01-01")
            .null("deletedAt")
            .or_group(
                FilterBuilder().contains("title", "Python"),
                FilterBuilder().contains("title", "Django")
            ))
        # Sorting
        .sort_by("publishedAt", SortDirection.DESC)
        .then_sort_by("views", SortDirection.DESC)
        # Pagination
        .paginate(page=1, page_size=20)
        # Population
        .populate(Populate()
            .add_field("author", fields=["name", "avatar", "bio"])
            .add_field("category")
            .add_field("comments",
                filters=FilterBuilder().eq("approved", True)))
        # Field selection
        .select(["title", "slug", "excerpt", "coverImage", "publishedAt"])
        # Locale & publication
        .with_locale("en")
        .with_publication_state(PublicationState.LIVE))

    # Execute query with type-safe response
    response = client.get_many("articles", query=query)

    # Access normalized data (works with both v4 and v5!)
    print(f"Total articles: {response.meta.pagination.total}")
    print(f"Page {response.meta.pagination.page} of {response.meta.pagination.page_count}")

    for article in response.data:
        # All responses are normalized to the same structure
        print(f"ID: {article.id}")
        print(f"Document ID: {article.document_id}")  # v5 only, None for v4
        print(f"Title: {article.attributes['title']}")
        print(f"Published: {article.published_at}")
        print("---")

CRUD Operations

Create, read, update, and delete entities:

from strapi_kit import StrapiConfig, StrapiQuery, SyncClient

config = StrapiConfig(base_url="http://localhost:1337", api_token="your-token")

with SyncClient(config) as client:
    # Create
    data = {"title": "New Article", "content": "Article body"}
    response = client.create("articles", data)
    created_id = response.data.document_id or str(response.data.id)
    document_id = response.data.document_id

    # Read one — pass document_id separately so `/`, `?`, `#`, `%` are encoded
    response = client.get_one("articles", document_id=created_id)
    article = response.data

    # Read many
    response = client.get_many("articles")
    all_articles = response.data

    # Update
    data = {"title": "Updated Title"}
    response = client.update("articles", data, document_id=created_id)

    # Draft-inclusive existence (v5 omitted status= is published).
    # exists() is document-level. exists_in_locale matches import probes
    # (published-then-draft, optional locale, Invalid key locale fallback).
    if document_id and client.exists("articles", document_id):
        print("document is published or draft")
    if document_id and client.exists_in_locale("articles", document_id, locale="fr"):
        print("fr published or draft is readable")

    # v5 Draft & Publish. publish() is stock REST PUT ?status=published.
    # classify_write_404: same-params GET hit → NotFoundError /
    # classified_from=write_rejected (refused write, not a missing token).
    # publish() remaining-draft 404 → AuthorizationError (lacks Publish).
    # update() draft-only stays NotFoundError / classified_from=draft_only.
    # unpublish() / discard_draft() need custom POST /actions/* routes
    # (not registered by stock Strapi 5 REST) and 404/405 if missing.
    # Leave classify_write_404 off (default) so a missing route stays
    # 404/405. Enable it only after those routes exist — probes use the
    # document path, not /actions/*, so a readable document + missing
    # route becomes write_rejected.
    if document_id:
        client.publish("articles", document_id, classify_write_404=True)
        client.unpublish("articles", document_id)
        client.discard_draft("articles", document_id)

    # Delete. classify_write_404: remaining draft → AuthorizationError
    # (stock DELETE removes drafts). Same-params published hit →
    # NotFoundError / write_rejected.
    response = client.remove(
        "articles",
        document_id=created_id,
        query=StrapiQuery().with_locale("en"),
        classify_write_404=True,
    )

    # String endpoints still work (caller must encode special characters):
    # client.get_one("articles/abc")
    # client.document_path("articles", created_id)  # articles/a%2Fb

Relation Writes (Strapi 5)

v5 REST relation writes take documentId strings, not numeric id. One-side fields are a documentId or None. Many-side fields use set / connect / disconnect. This helper does not emit v4 { connect: [{ id: 1 }] } shapes.

from strapi_kit import RelationWriteOp, SyncClient, relation_write

with SyncClient(config) as client:
    # One-side: documentId string or None
    client.create("articles", {
        "title": "New Article",
        "author": relation_write(document_ids=["authorDocId"], multiple=False),
    })

    # Many-side replace (default op is set)
    client.update("articles/articleDocId", {
        "categories": relation_write(
            document_ids=["catDoc1", "catDoc2"],
            multiple=True,
        ),
    })

    # Incremental many-side updates
    client.update("articles/articleDocId", {
        "categories": relation_write(
            document_ids=["catDoc3"],
            multiple=True,
            op=RelationWriteOp.CONNECT,
        ),
    })
    client.update("articles/articleDocId", {
        "categories": relation_write(
            document_ids=["catDoc1"],
            multiple=True,
            op=RelationWriteOp.DISCONNECT,
        ),
    })

{"documentId": "..."} objects are accepted and normalized to short strings.

Media Upload/Download

Upload, download, and manage media files in Strapi's media library:

from strapi_kit import SyncClient, StrapiConfig
from strapi_kit.models import StrapiQuery, FilterBuilder

config = StrapiConfig(base_url="http://localhost:1337", api_token="your-token")

with SyncClient(config) as client:
    # Upload a file
    media = client.upload_file(
        "hero-image.jpg",
        alternative_text="Hero image",
        caption="Main article hero image"
    )
    print(f"Uploaded: {media.name} (ID: {media.id})")
    print(f"URL: {media.url}")

    # Upload and attach to an entity
    cover = client.upload_file(
        "cover.jpg",
        ref="api::article.article",
        ref_id="abc123",  # Article documentId or numeric ID
        field="cover"
    )

    # Upload multiple files
    files = ["image1.jpg", "image2.jpg", "image3.jpg"]
    media_list = client.upload_files(files, folder="gallery")
    print(f"Uploaded {len(media_list)} files")

    # List media library
    response = client.list_media()
    for item in response.data:
        print(f"{item.attributes['name']}: {item.attributes['url']}")

    # List with filters
    query = (StrapiQuery()
        .filter(FilterBuilder().eq("mime", "image/jpeg"))
        .paginate(page=1, page_size=10))
    response = client.list_media(query)

    # Get specific media details
    media = client.get_media(42)
    print(f"Name: {media.name}, Size: {media.size} KB")

    # Download a file
    content = client.download_file(media.url)
    print(f"Downloaded {len(content)} bytes")

    # Download and save
    client.download_file(
        media.url,
        save_path="downloaded_image.jpg"
    )

    # Update media metadata
    updated = client.update_media(
        42,
        alternative_text="Updated alt text",
        caption="Updated caption"
    )

    # Delete media
    client.delete_media(42)

Async version:

import asyncio
from strapi_kit import AsyncClient, StrapiConfig

async def main():
    config = StrapiConfig(base_url="http://localhost:1337", api_token="your-token")

    async with AsyncClient(config) as client:
        # All methods have async equivalents
        media = await client.upload_file("image.jpg")
        content = await client.download_file(media.url)
        await client.delete_media(media.id)

asyncio.run(main())

Media Features:

  • Upload single or multiple files
  • Attach uploads to specific entities (articles, pages, etc.)
  • Set metadata (alt text, captions)
  • Download with streaming for large files
  • Query media library with filters
  • Update metadata without re-uploading
  • Full support for both sync and async

Admin Information and Origin Paths

Content API, Content-Type Builder, and upload endpoints stay under /api. Admin routes such as /admin/information are origin-rooted (not under /api).

get("admin/information") still becomes {base}/api/admin/information so existing callers are unchanged. Use api_prefix=False or get_admin_information() for the real admin route:

from strapi_kit import SyncClient, StrapiConfig
from strapi_kit.models import AdminInformation

config = StrapiConfig(base_url="http://localhost:1337", api_token="your-token")

with SyncClient(config) as client:
    # Unchanged: still prefixes /api
    client.get("admin/information")  # GET {base}/api/admin/information

    # Escape hatch for origin-rooted paths
    client.request("GET", "admin/information", api_prefix=False)
    client.get("admin/information", api_prefix=False)

    # First-class probe: GET {base}/admin/information
    info: AdminInformation = client.get_admin_information()
    print(info.strapi_version)  # from strapiVersion or data.strapiVersion
    print(info.raw)  # original JSON; missing version is still success

Async version:

async with AsyncClient(config) as client:
    info = await client.get_admin_information()
    print(info.strapi_version)

Content-Type Builder API

Query Strapi's Content-Type Builder to discover schemas, content types, and components:

from strapi_kit import SyncClient, StrapiConfig

config = StrapiConfig(base_url="http://localhost:1337", api_token="your-token")

with SyncClient(config) as client:
    # List all content types (excludes plugins by default)
    content_types = client.get_content_types()
    for ct in content_types:
        print(f"{ct.uid}: {ct.info.display_name} D&P={ct.draft_and_publish}")
        # api::article.article: Article D&P=True
        # api::category.category: Category D&P=None  # unknown, not False

    # Include plugin content types
    all_types = client.get_content_types(include_plugins=True)

    # List all components
    components = client.get_components()
    for comp in components:
        print(f"{comp.category}/{comp.uid}: {comp.info.display_name}")
        # shared/shared.seo: SEO
        # blocks/blocks.hero: Hero Section

    # Get full schema for a content type
    schema = client.get_content_type_schema("api::article.article")
    print(f"Display name: {schema.display_name}")
    print(f"Plural name: {schema.plural_name}")
    print(f"Draft & Publish: {schema.draft_and_publish}")  # True | False | None

    # Check field types
    print(schema.get_field_type("title"))  # "string"
    print(schema.is_relation_field("author"))  # True
    print(schema.get_relation_target("author"))  # "api::author.author"

    # Check for components
    print(schema.is_component_field("seo"))  # True
    print(schema.get_component_uid("seo"))  # "shared.seo"

Async version:

async with AsyncClient(config) as client:
    content_types = await client.get_content_types()
    components = await client.get_components()
    schema = await client.get_content_type_schema("api::article.article")

Collection REST endpoints

Strapi REST collections are addressed by schema.pluralName (articles, blog-posts, people). The content-type UID (api::post.post) is not a URL path — do not append s, use apiID, or split the UID.

Use collection_endpoint() to read pluralName / info.plural_name, then pass that string to get_many, create, and get_one:

from strapi_kit import SyncClient, collection_endpoint, document_endpoint

with SyncClient(config) as client:
    for ct in client.get_content_types():
        # "blog-posts" even when uid is "api::post.post"
        endpoint = collection_endpoint(ct)
        response = client.get_many(endpoint)
        created = client.create(endpoint, {"title": "Hello"})
        one = client.get_one(document_endpoint(ct, created.data.document_id or created.data.id))

collection_endpoint() raises ValidationError if pluralName is missing, blank, or not a string (the only honest answer when the schema cannot be addressed). document_endpoint() joins the collection id with a percent-encoded document id so characters like /, ?, space, and % are safe in the path; a blank document_id raises ValidationError.

UID Utilities

Utility functions for working with Strapi content type UIDs:

from strapi_kit.utils import (
    uid_to_endpoint,
    uid_to_api_id,  # Alias for uid_to_endpoint
    api_id_to_singular,
    uid_to_admin_url,
    extract_model_name,
    is_api_content_type,
)

# Heuristic only — prefer collection_endpoint(schema) for REST paths
uid_to_endpoint("api::article.article")  # "articles"
uid_to_endpoint("api::category.category")  # "categories"
uid_to_endpoint("api::class.class")  # "classes"

# Convert plural API ID to singular
api_id_to_singular("articles")  # "article"
api_id_to_singular("categories")  # "category"
api_id_to_singular("quizzes")  # "quiz" (handles -zzes endings)
api_id_to_singular("people")  # "person" (handles irregular plurals)
api_id_to_singular("children")  # "child"

# Build admin panel URL
uid_to_admin_url("api::article.article", "http://localhost:1337")
# "http://localhost:1337/admin/content-manager/collection-types/api::article.article"

uid_to_admin_url("api::homepage.homepage", "http://localhost:1337", kind="singleType")
# "http://localhost:1337/admin/content-manager/single-types/api::homepage.homepage"

# Extract model name from UID
extract_model_name("api::article.article")  # "article"
extract_model_name("plugin::users-permissions.user")  # "user"

# Check if UID is an API content type
is_api_content_type("api::article.article")  # True
is_api_content_type("plugin::users-permissions.user")  # False

Strapi v5 Blocks ↔ Markdown

Strapi 5 rich text is stored as blocks JSON (not a markdown string). Classic richtext fields remain markdown/strings and are not converted here.

from strapi_kit import (
    FieldType,
    MarkdownConversion,
    blocks_to_markdown,
    markdown_to_blocks,
)

# CTB attribute type "blocks" is a first-class FieldType
assert FieldType.BLOCKS == "blocks"

# Read path: official nodes → markdown (lossy cases are never silent)
conversion: MarkdownConversion = blocks_to_markdown(entity.attributes["body"])
print(conversion.markdown)
print(conversion.lossy_reasons)  # () iff faithful

# Write path: CommonMark subset → blocks (marks, links, images, nested lists)
payload = {
    "title": "Hello",
    "body": markdown_to_blocks("# Title\n\nA paragraph with **bold**"),
}
client.create("articles", payload)

blocks_to_markdown supports

  • Blocks: paragraph, heading (levels 1–6), list (ordered / unordered) + list-item, quote, code, image, link, text
  • Marks: bold, italic, strikethrough, code
  • Markdown metacharacters in plain-text leaves are escaped before marks so source **literal** cannot invent formatting

Lossy, never silent (reason recorded in lossy_reasons, deduplicated):

  • underline — text kept (no markdown equivalent)
  • image/link without a URL — image dropped / link text kept
  • unknown block/inline types — flattened to plain text or dropped
  • malformed (non-object) nodes — skipped
  • trees deeper than 32 nodes — remaining subtree skipped (guards recursion bombs and cyclic children)

markdown_to_blocks is a best-effort write path, not a full CommonMark AST:

  • Headings, paragraphs, fenced code, ordered/unordered lists (including nested), blockquotes
  • Inline marks (**bold**, _italic_, ~~strike~~, `code`), links, and images
  • Images are not uploaded; a lone ![alt](url) becomes a root image node
  • Empty / whitespace-only input pins one empty paragraph: [{"type": "paragraph", "children": [{"type": "text", "text": ""}]}]

SEO Configuration Detection

Detect SEO configuration patterns in content type schemas:

from strapi_kit.utils import detect_seo_configuration, SEOConfiguration

# Detect SEO in a schema dict
schema = {
    "uid": "api::article.article",
    "attributes": {
        "title": {"type": "string"},
        "seo": {"type": "component", "component": "shared.seo"},
    }
}

config = detect_seo_configuration(schema)
print(config.has_seo)  # True
print(config.seo_type)  # "component"
print(config.seo_field_name)  # "seo"
print(config.seo_component_uid)  # "shared.seo"
print(config.fields)  # {"title": "seo.metaTitle", "description": "seo.metaDescription", ...}

# Also detects flat SEO fields
schema_flat = {
    "uid": "api::page.page",
    "attributes": {
        "metaTitle": {"type": "string"},
        "metaDescription": {"type": "text"},
        "ogImage": {"type": "media"},
    }
}

config = detect_seo_configuration(schema_flat)
print(config.has_seo)  # True
print(config.seo_type)  # "flat"
print(config.fields)  # {"title": "metaTitle", "description": "metaDescription", "og_image": "ogImage"}

Supported SEO patterns:

  • Component-based: Fields named seo, meta, metadata with type component
  • Component UIDs: Components with seo in the UID (e.g., shared.seo, custom.page-seo)
  • Flat fields: metaTitle, meta_title, seoTitle, metaDescription, ogTitle, canonicalUrl, noIndex, etc.

Export/Import with Relation Resolution

strapi-kit provides comprehensive export/import functionality with automatic relation resolution for migrating content between Strapi instances.

from strapi_kit import StrapiConfig, StrapiExporter, StrapiImporter, SyncClient

# Export from source instance
source_config = StrapiConfig(
    base_url="http://localhost:1337",
    api_token="source-token"
)

with SyncClient(source_config) as client:
    exporter = StrapiExporter(client)

    # Export content types with schemas for relation resolution.
    # Default document_status=DRAFT (v5 status=draft). Pass
    # document_status=None for published-only.
    export_data = exporter.export_content_types([
        "api::article.article",
        "api::author.author",
        "api::category.category"
    ])

    # Save to file
    exporter.save_to_file(export_data, "migration.json")

# Import to target instance
target_config = StrapiConfig(
    base_url="http://localhost:1338",
    api_token="target-token"
)

with SyncClient(target_config) as client:
    importer = StrapiImporter(client)

    # Load export
    export_data = StrapiExporter.load_from_file("migration.json")

    # Import with automatic relation resolution
    result = importer.import_data(export_data)

    print(f"Imported {result.entities_imported} entities")
    print(f"ID mapping: {result.id_mapping}")

Export/Import Features:

  • Automatic Relation Resolution: Relations are automatically mapped using content type schemas
  • Schema Caching: Content type metadata cached for fast relation lookups
  • ID Mapping: Old IDs automatically mapped to new IDs during import
  • Media Support: Export and import media files with content
  • Progress Tracking: Optional callbacks for monitoring long operations
  • Dry Run Mode: Test imports before executing
  • Conflict Resolution: Configurable strategies for handling existing entities

How Relation Resolution Works:

  1. During export, content type schemas are fetched from the Content-Type Builder API
  2. Schemas include relation metadata (field types, targets)
  3. During import, relations are resolved by looking up target content types from schemas
  4. Old IDs are mapped to new IDs using the ID mapping table

For example, when importing an article with {"author": [5]}, the system:

  • Looks up the schema to find that author targets "api::author.author"
  • Maps old author ID 5 to the new ID in the target instance
  • Updates the article with the resolved relation

See the Export/Import Guide for complete documentation.

Complete Migration Examples

We provide two complete migration examples for different use cases:

Simple Migration (Quick Start)

Perfect for straightforward migrations with known content types:

# Set environment variables (or edit the script)
export SOURCE_STRAPI_TOKEN='your-source-token'
export TARGET_STRAPI_TOKEN='your-target-token'

# Run the migration
python examples/simple_migration.py

Features:

  • ✅ Single-file, easy to understand
  • ✅ Environment variable support for credentials
  • ✅ Configuration validation before migration
  • ✅ Connection verification for both instances
  • ✅ Timestamped backup files to prevent overwrites
  • ✅ Comprehensive error handling
  • ✅ Automatic relation resolution
  • ✅ Includes media files

Full Migration (Production-Ready)

Comprehensive migration tool with auto-discovery and verification:

# Set environment variables (required)
export SOURCE_STRAPI_URL="http://localhost:1337"
export SOURCE_STRAPI_TOKEN="your-source-api-token"
export TARGET_STRAPI_URL="http://localhost:1338"
export TARGET_STRAPI_TOKEN="your-target-api-token"

# Export all content from source
python examples/full_migration_v5.py export

# Import to target
python examples/full_migration_v5.py import

# Or do both in one command
python examples/full_migration_v5.py migrate

# Verify migration success
python examples/full_migration_v5.py verify

Features:

  • Environment variable configuration (no hardcoded credentials)
  • Auto-discovers all content types (no manual configuration needed)
  • ✅ Progress bars for long operations
  • ✅ Detailed migration reports
  • ✅ Entity count verification
  • ✅ Error reporting and recovery
  • ✅ Batch processing for large datasets
  • ✅ ID mapping with detailed logs
  • ✅ Media file handling with progress tracking

Full Migration Example Output:

🔍 Discovering content types...
   Found 12 content types:
   - api::article.article
   - api::author.author
   - api::category.category
   ...

📥 Exporting 12 content types...
[████████████████████████████████████████] 100% | Processing articles

✅ EXPORT COMPLETE
Content types exported: 12
Total entities exported: 1,847
Media files downloaded: 234
Total export size: 45.3 MB

📤 Importing 1,847 entities...
[████████████████████████████████████████] 100% | Importing articles

✅ IMPORT COMPLETE
Entities imported: 1,847
Media files imported: 234

Both examples include:

  • SecretStr for secure token handling
  • Proper error handling and reporting
  • Progress tracking
  • Automatic relation resolution using schemas
  • Media file download/upload
  • ID mapping for relations

Dependency Injection

strapi-kit supports full dependency injection for testability and customization. All dependencies have sensible defaults but can be overridden.

Why DI?

  • Testability: Inject mocks for unit testing without HTTP calls
  • Customization: Provide custom parsers, auth handlers, or HTTP clients
  • Flexibility: Share HTTP clients across multiple Strapi instances
  • Control: Manage lifecycles of shared resources

Basic DI Example

from strapi_kit import SyncClient, StrapiConfig
import httpx

config = StrapiConfig(
    base_url="http://localhost:1337",
    api_token="your-token"
)

# Simple usage - all dependencies created automatically
with SyncClient(config) as client:
    response = client.get_many("articles")

# Advanced usage - inject custom HTTP client
shared_http = httpx.Client()
client1 = SyncClient(config, http_client=shared_http)
client2 = SyncClient(config, http_client=shared_http)
# Both share the same connection pool

Injectable Dependencies

from strapi_kit import (
    SyncClient,
    AsyncClient,
    StrapiConfig,
    AuthProvider,
    HTTPClient,
    AsyncHTTPClient,
    ResponseParser,
    VersionDetectingParser,
)

# Custom authentication
class CustomAuth:
    def get_headers(self) -> dict[str, str]:
        return {"Authorization": "Custom token"}

    def validate_token(self) -> bool:
        return True

# Custom response parser
class CustomParser:
    def parse_single(self, response_data):
        # Custom parsing logic
        ...

    def parse_collection(self, response_data):
        # Custom parsing logic
        ...

# Inject custom dependencies
client = SyncClient(
    config,
    http_client=custom_http,      # Custom HTTP client
    auth=custom_auth,               # Custom auth provider
    parser=custom_parser            # Custom response parser
)

Testing with DI

from unittest.mock import Mock

# Create mock HTTP client for testing (no actual HTTP calls)
class MockHTTPClient:
    def __init__(self):
        self.requests = []

    def request(self, method, url, **kwargs):
        self.requests.append((method, url))
        # Return mock response
        mock_response = Mock()
        mock_response.is_success = True
        mock_response.json.return_value = {"data": []}
        return mock_response

    def close(self):
        pass

# Use mock in tests
mock_http = MockHTTPClient()
client = SyncClient(config, http_client=mock_http)

# Make requests (no actual HTTP)
client.get("articles")

# Verify mock was called
assert len(mock_http.requests) == 1

Protocols (Type Interfaces)

strapi-kit uses Python protocols for dependency interfaces:

  • ConfigProvider: Configuration interface
  • AuthProvider: Authentication interface
  • HTTPClient: Sync HTTP client interface
  • AsyncHTTPClient: Async HTTP client interface
  • ResponseParser: Response parsing interface

All implementations satisfy these protocols and are type-checked with mypy.

Example - Custom config from database:

class DatabaseConfig:
    """Load config from database."""

    def __init__(self, db):
        self.db = db

    def get_base_url(self) -> str:
        return self.db.query("SELECT url FROM config")[0]

    def get_api_token(self) -> str:
        return self.db.query("SELECT token FROM secrets")[0]

    # ... other properties

# Use database config
db_config = DatabaseConfig(db_connection)
client = SyncClient(db_config)

Error Handling

strapi-kit provides a rich exception hierarchy for precise error handling:

from strapi_kit import (
    StrapiError,           # Base for all errors
    ConfigurationError,    # Invalid config (missing token, bad URL)
    ValidationError,       # Invalid input/query params
    AuthenticationError,   # HTTP 401
    AuthorizationError,    # HTTP 403
    NotFoundError,         # HTTP 404
    ConflictError,         # HTTP 409
    ServerError,           # HTTP 5xx
    NetworkError,          # Connection issues (base)
    RateLimitError,        # HTTP 429
    ImportExportError,     # Data operations (base)
    FormatError,           # Invalid data format
    MediaError,            # Media upload/download errors
)

try:
    with SyncClient(config) as client:
        response = client.get_many("articles")
except ConfigurationError as e:
    print(f"Config issue: {e}")
except ValidationError as e:
    print(f"Invalid query: {e}")
except NotFoundError as e:
    print(f"Not found: {e}")
except StrapiError as e:
    print(f"Strapi error: {e}")

Strapi reports unique-index collisions as HTTP 400/422 ValidationError (not ConflictError). Use the helpers to tell "slug already taken" from a malformed payload:

from strapi_kit.exceptions import (
    ValidationError,
    format_validation_errors,
    is_uniqueness_violation,
)

try:
    client.create("articles", {"slug": "hello", "title": "Hello"})
except ValidationError as e:
    if is_uniqueness_violation(e):
        print("Unique field already taken")
        print(format_validation_errors(e) or str(e))
        # e.field_errors -> [("slug", "This attribute must be unique")]
    else:
        print(f"Invalid payload: {e}")

All exceptions inherit from StrapiError, making it easy to catch all package-specific errors while still allowing precise handling of specific error types.

Development

Setup

# Clone the repository
git clone https://github.com/mehdizare/strapi-kit.git
cd strapi-kit

# Create virtual environment
python -m venv .venv
source .venv/bin/activate  # On Windows: .venv\Scripts\activate

# Install dependencies including pre-commit (uv recommended)
uv pip install -e ".[dev]"
# Or with pip
pip install -e ".[dev]"

# Install pre-commit hooks (one-time setup)
pre-commit install

Pre-commit Hooks

This project uses pre-commit hooks to ensure code quality. The pre-commit package is included in [dev] dependencies.

# Install hooks (one-time setup, after installing dev dependencies)
pre-commit install

# Run hooks manually on all files
pre-commit run --all-files

# Update hooks to latest versions
pre-commit autoupdate

What the hooks check:

  • ✅ Code formatting (ruff format)
  • ✅ Linting (ruff check)
  • ✅ Type checking (mypy strict mode)
  • ✅ Security issues (ruff S rules)
  • ✅ Secrets detection (detect-secrets)
  • ✅ File consistency (trailing whitespace, EOF, etc.)

Skip hooks temporarily (not recommended):

git commit --no-verify

Testing

# Run all tests
pytest

# Run with coverage
pytest --cov=strapi_kit --cov-report=html

# Run specific test file
pytest tests/unit/test_client.py -v

Code Quality

# Format code
ruff format src/ tests/

# Lint code
ruff check src/ tests/

# Type checking
mypy src/strapi_kit/

# Security checks
make security

# Run all quality checks
make quality

Project Status

This project is in active development. Currently implemented:

✅ Phase 1: Core Infrastructure (Complete)

  • HTTP clients (sync and async)
  • Configuration with Pydantic
  • Authentication (API tokens)
  • Exception hierarchy
  • API version detection (v4/v5)

✅ Phase 2: Type-Safe Query Builder (Complete)

  • Request Models: Filters (24 operators), sorting, pagination, population, field selection
  • Response Models: V4/V5 parsing with automatic normalization
  • Query Builder: StrapiQuery fluent API with full type safety
  • Typed Client Methods: get_one(), get_many(), create(), update(), remove(), exists(), exists_in_locale(), publish(), unpublish(), discard_draft()
  • Dependency Injection: Full DI support with protocols for testability
  • Full test coverage with type-safe query building

✅ Phase 3: Media Operations (Complete)

  • Media Upload: Single and batch file uploads with metadata
  • Media Download: Streaming downloads for large files
  • Media Management: List, get, update, and delete media
  • Entity Attachment: Link media to specific content types
  • Full async support for all media operations
  • 100% test coverage on media operations

✅ Phase 4: Export/Import (Complete)

  • Content Export: Export content types with all entities
  • Automatic Relation Resolution: Schema-based relation mapping
  • Media Export: Download and package media files
  • Content Import: Import with ID mapping and relation resolution
  • Schema Caching: Efficient content type metadata handling
  • Import/Export coverage in unit tests (live e2e is make e2e)

✅ Phase 5: Schema Introspection (Complete)

  • Content-Type Builder API: List content types, components, and full schemas
  • UID Utilities: Convert UIDs to endpoints, singularize, build admin URLs
  • SEO Detection: Detect SEO configuration patterns in schemas
  • Draft & Publish: First-class draft_and_publish (True / False / None)

✅ Phase 6: Strapi 5 document surface (Complete in 0.2.0)

  • Document actions: stock REST publish(); custom-route unpublish / discard_draft
  • Query: DocumentStatus, PublicationFilter, draft-inclusive exists()
  • Blocks ↔ Markdown: blocks_to_markdown / markdown_to_blocks
  • Relation writes: relation_write() / RelationWriteOp
  • Streaming export: pagination echo + default draft completeness

GraphQL is out of scope (REST only).

Key Features

  • Type-Safe: Full Pydantic validation and mypy strict mode compliance
  • Version Agnostic: Works with both Strapi v4 and v5 seamlessly
  • 24 Filter Operators: Complete filtering support (eq, gt, contains, in, null, between, etc.)
  • Normalized Responses: Consistent interface regardless of Strapi version
  • Dependency Injection: Protocol-based DI for testability and customization
  • IDE Autocomplete: Full type hints for excellent developer experience
  • Dual API: Use typed methods for safety or raw methods for flexibility

License

MIT License - see LICENSE file for details.

Contributing

Contributions are welcome! Please feel free to submit a Pull Request.

Development Process

  1. Fork the repository
  2. Create a feature branch (git checkout -b feature/amazing-feature)
  3. Make your changes and add tests
  4. Run quality checks: make pre-commit
  5. Commit your changes with conventional commits format
  6. Push to your fork and submit a Pull Request

Automated Reviews: All PRs are automatically reviewed by CodeRabbit AI for code quality, security, and best practices.

Download files

Download the file for your platform. If you're not sure which to choose, learn more about installing packages.

Source Distribution

strapi_kit-0.6.0.tar.gz (251.8 kB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

strapi_kit-0.6.0-py3-none-any.whl (172.7 kB view details)

Uploaded Python 3

File details

Details for the file strapi_kit-0.6.0.tar.gz.

File metadata

  • Download URL: strapi_kit-0.6.0.tar.gz
  • Upload date:
  • Size: 251.8 kB
  • Tags: Source
  • Uploaded using Trusted Publishing? Yes
  • Uploaded via: twine/7.0.0 CPython/3.13.14

File hashes

Hashes for strapi_kit-0.6.0.tar.gz
Algorithm Hash digest
SHA256 0e8ea79ca6e3521dfdbb79900118161b8e0723b629f711025af044efbb151da3
MD5 c81ee21e978951ce5b4dc766591de6b1
BLAKE2b-256 b9a9eabf4c54eec7652f902c19964a5cb95e915c38d5c0f7e2c0e4dcb0398242

See more details on using hashes here.

Provenance

The following attestation bundles were made for strapi_kit-0.6.0.tar.gz:

Publisher: release.yml on MehdiZare/strapi-kit

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

File details

Details for the file strapi_kit-0.6.0-py3-none-any.whl.

File metadata

  • Download URL: strapi_kit-0.6.0-py3-none-any.whl
  • Upload date:
  • Size: 172.7 kB
  • Tags: Python 3
  • Uploaded using Trusted Publishing? Yes
  • Uploaded via: twine/7.0.0 CPython/3.13.14

File hashes

Hashes for strapi_kit-0.6.0-py3-none-any.whl
Algorithm Hash digest
SHA256 e51622d6ecca024aebea1d4f5c0d6c00a30a70fd36dda4b216e5af5199120ed2
MD5 d50db47acbcb922535e1da049d8f6c15
BLAKE2b-256 fc8a836be488beaf967be827b5a607ff379cac03724a4c9fe5762ab3a9373ea7

See more details on using hashes here.

Provenance

The following attestation bundles were made for strapi_kit-0.6.0-py3-none-any.whl:

Publisher: release.yml on MehdiZare/strapi-kit

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

Release history Release notifications | RSS feed

This release

0.6.0 This release

2 files

0.5.0

2 files

0.4.0

2 files

0.3.0

2 files

0.2.0

2 files

0.1.0

2 files

0.0.6

2 files

0.0.5

2 files

0.0.4

2 files

0.0.3

2 files

0.0.2

2 files

0.0.1

2 files

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page