Metadata-Version: 2.5
Name: strapi-kit
Version: 0.2.0
Summary: A modern Python client for Strapi CMS with import/export capabilities
Project-URL: Homepage, https://github.com/mehdizare/strapi-kit
Project-URL: Documentation, https://mehdizare.github.io/strapi-kit/
Project-URL: Repository, https://github.com/mehdizare/strapi-kit
Project-URL: Issues, https://github.com/mehdizare/strapi-kit/issues
Author: Mehdi Zare
License: MIT
License-File: LICENSE
Keywords: api,client,cms,export,import,strapi
Classifier: Development Status :: 4 - Beta
Classifier: Intended Audience :: Developers
Classifier: License :: OSI Approved :: MIT License
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3.13
Classifier: Programming Language :: Python :: 3.14
Classifier: Topic :: Software Development :: Libraries :: Python Modules
Requires-Python: >=3.12
Requires-Dist: httpx>=0.28.1
Requires-Dist: orjson>=3.12.0
Requires-Dist: pydantic-settings<3.0,>=2.15.0
Requires-Dist: pydantic<3.0,>=2.13.4
Requires-Dist: python-dateutil>=2.9.0.post0
Requires-Dist: tenacity>=9.1.4
Requires-Dist: typing-extensions>=4.16.0
Provides-Extra: dev
Requires-Dist: detect-secrets>=1.5.0; extra == 'dev'
Requires-Dist: mypy>=2.3.1; extra == 'dev'
Requires-Dist: pre-commit>=4.6.2; extra == 'dev'
Requires-Dist: pytest-asyncio>=1.4.0; extra == 'dev'
Requires-Dist: pytest-cov>=7.1.0; extra == 'dev'
Requires-Dist: pytest-mock>=3.15.1; extra == 'dev'
Requires-Dist: pytest>=9.1.1; extra == 'dev'
Requires-Dist: respx>=0.23.1; extra == 'dev'
Requires-Dist: ruff>=0.16.3; extra == 'dev'
Requires-Dist: safety<4.0.0,>=3.8.1; extra == 'dev'
Provides-Extra: docs
Requires-Dist: mkdocs-material>=9.7.7; extra == 'docs'
Requires-Dist: mkdocs>=1.6.1; extra == 'docs'
Requires-Dist: mkdocstrings[python]>=1.0.6; extra == 'docs'
Description-Content-Type: text/markdown

# strapi-kit

[![CI](https://github.com/MehdiZare/strapi-kit/actions/workflows/ci.yml/badge.svg)](https://github.com/MehdiZare/strapi-kit/actions/workflows/ci.yml)
[![codecov](https://codecov.io/gh/MehdiZare/strapi-kit/graph/badge.svg)](https://codecov.io/gh/MehdiZare/strapi-kit)
[![PyPI version](https://img.shields.io/pypi/v/strapi-kit.svg)](https://pypi.org/project/strapi-kit/)
[![Python versions](https://img.shields.io/pypi/pyversions/strapi-kit.svg)](https://pypi.org/project/strapi-kit/)
[![License](https://img.shields.io/pypi/l/strapi-kit.svg)](https://github.com/MehdiZare/strapi-kit/blob/main/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

```bash
pip install strapi-kit
```

Or with uv (recommended for faster installs):

```bash
uv pip install strapi-kit
```

For development:

```bash
# 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:

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

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

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

```bash
# .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:

```python
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):

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

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

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

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

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

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

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

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

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

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

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

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

```python
from strapi_kit.models import StrapiQuery

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

### Locale & Publication State

For i18n and draft/publish workflows:

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

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

```python
from strapi_kit import SyncClient, StrapiConfig

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)
    if document_id and client.exists("articles", document_id):
        print("document is published or draft")

    # v5 Draft & Publish. publish() is stock REST PUT ?status=published.
    # unpublish() / discard_draft() need custom POST /actions/* routes
    # (not registered by stock Strapi 5 REST) and 404/405 if missing.
    if document_id:
        client.publish("articles", document_id)
        client.unpublish("articles", document_id)
        client.discard_draft("articles", document_id)

    # Delete. classify_write_404 remaps a 404 to AuthorizationError when
    # a draft GET still finds the document (token likely lacks Update/Publish).
    response = client.remove("articles", document_id=created_id, 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.

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

```python
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:**

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

```python
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:**

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

```python
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:**

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

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

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

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

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

```python
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](docs/export-import.md) 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:

```bash
# 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:

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

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

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

```python
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**:
```python
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:

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

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

```bash
# 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.

```bash
# 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):
```bash
git commit --no-verify
```

### Testing

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

```bash
# 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()`, `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.
