pryscraper/apify_schema.py
cryptorugmunch 07288a01d7
All checks were successful
CI / typecheck (push) Successful in 51s
CI / Secret scan (gitleaks) (push) Successful in 32s
CI / lint (push) Successful in 47s
CI / Security audit (bandit) (push) Successful in 35s
CI / test (push) Successful in 1m20s
feat(db): add Alembic migrations (#6)
2026-07-03 02:22:33 +02:00

429 lines
12 KiB
Python

"""Pry — Apify actor input/output schema builder.
Provides a fluent, typed API for constructing Apify actor input and output
schemas (JSON Schema compatible with Apify's `INPUT_SCHEMA.json` and
`OUTPUT_SCHEMA.json`).
Example:
builder = ApifySchemaBuilder("Product Scraper")
builder.add_string("url", "Start URL", required=True)
builder.add_integer("maxResults", "Max Results", default=10, min=1, max=100)
builder.add_enum("format", "Output Format", ["json", "csv", "markdown"], default="json")
schema = builder.build_input_schema()
"""
# SPDX-License-Identifier: MIT
# Copyright (c) 2026 Rug Munch Media LLC
#
# Part of Pry — https://git.rugmunch.io/RugMunchMedia/pryscraper
# Licensed under MIT.
from __future__ import annotations
from typing import Any
class FieldSpec:
"""Base class for Apify schema field specifications."""
def __init__(
self,
name: str,
title: str,
field_type: str,
description: str = "",
default: Any | None = None,
nullable: bool = False,
) -> None:
self.name = name
self.title = title
self.field_type = field_type
self.description = description
self.default = default
self.nullable = nullable
def to_dict(self) -> dict[str, Any]:
spec: dict[str, Any] = {
"title": self.title,
"type": ([self.field_type, "null"] if self.nullable else self.field_type),
}
if self.description:
spec["description"] = self.description
if self.default is not None:
spec["default"] = self.default
return spec
class StringField(FieldSpec):
"""String field with optional pattern and editor hints."""
def __init__(
self,
name: str,
title: str,
description: str = "",
default: str | None = None,
nullable: bool = False,
min_length: int | None = None,
max_length: int | None = None,
pattern: str | None = None,
editor: str | None = None,
) -> None:
super().__init__(name, title, "string", description, default, nullable)
self.min_length = min_length
self.max_length = max_length
self.pattern = pattern
self.editor = editor
def to_dict(self) -> dict[str, Any]:
spec = super().to_dict()
if self.min_length is not None:
spec["minLength"] = self.min_length
if self.max_length is not None:
spec["maxLength"] = self.max_length
if self.pattern is not None:
spec["pattern"] = self.pattern
if self.editor is not None:
spec["editor"] = self.editor
return spec
class IntegerField(FieldSpec):
"""Integer field with optional range."""
def __init__(
self,
name: str,
title: str,
description: str = "",
default: int | None = None,
nullable: bool = False,
minimum: int | None = None,
maximum: int | None = None,
) -> None:
super().__init__(name, title, "integer", description, default, nullable)
self.minimum = minimum
self.maximum = maximum
def to_dict(self) -> dict[str, Any]:
spec = super().to_dict()
if self.minimum is not None:
spec["minimum"] = self.minimum
if self.maximum is not None:
spec["maximum"] = self.maximum
return spec
class NumberField(FieldSpec):
"""Number field with optional range."""
def __init__(
self,
name: str,
title: str,
description: str = "",
default: float | None = None,
nullable: bool = False,
minimum: float | None = None,
maximum: float | None = None,
) -> None:
super().__init__(name, title, "number", description, default, nullable)
self.minimum = minimum
self.maximum = maximum
def to_dict(self) -> dict[str, Any]:
spec = super().to_dict()
if self.minimum is not None:
spec["minimum"] = self.minimum
if self.maximum is not None:
spec["maximum"] = self.maximum
return spec
class BooleanField(FieldSpec):
"""Boolean field with optional default."""
def __init__(
self,
name: str,
title: str,
description: str = "",
default: bool | None = None,
nullable: bool = False,
) -> None:
super().__init__(name, title, "boolean", description, default, nullable)
class EnumField(FieldSpec):
"""Enum field restricted to a set of allowed values."""
def __init__(
self,
name: str,
title: str,
enum: list[str],
description: str = "",
default: str | None = None,
nullable: bool = False,
) -> None:
super().__init__(name, title, "string", description, default, nullable)
self.enum = enum
def to_dict(self) -> dict[str, Any]:
spec = super().to_dict()
spec["enum"] = self.enum
return spec
class ArrayField(FieldSpec):
"""Array field with item schema."""
def __init__(
self,
name: str,
title: str,
items: dict[str, Any],
description: str = "",
default: list[Any] | None = None,
nullable: bool = False,
min_items: int | None = None,
max_items: int | None = None,
) -> None:
super().__init__(name, title, "array", description, default, nullable)
self.items = items
self.min_items = min_items
self.max_items = max_items
def to_dict(self) -> dict[str, Any]:
spec = super().to_dict()
spec["items"] = self.items
if self.min_items is not None:
spec["minItems"] = self.min_items
if self.max_items is not None:
spec["maxItems"] = self.max_items
return spec
class ObjectField(FieldSpec):
"""Object field with nested properties."""
def __init__(
self,
name: str,
title: str,
properties: dict[str, Any],
description: str = "",
default: dict[str, Any] | None = None,
nullable: bool = False,
required: list[str] | None = None,
) -> None:
super().__init__(name, title, "object", description, default, nullable)
self.properties = properties
self.required = required or []
def to_dict(self) -> dict[str, Any]:
spec = super().to_dict()
spec["properties"] = self.properties
if self.required:
spec["required"] = self.required
return spec
class ApifySchemaBuilder:
"""Fluent builder for Apify actor input/output schemas."""
def __init__(
self, title: str = "Actor Input", description: str = "", schema_version: int = 1
) -> None:
self.title = title
self.description = description
self.schema_version = schema_version
self._fields: dict[str, FieldSpec] = {}
self._required: list[str] = []
self._prefab: str | None = None
def add_field(self, field: FieldSpec, required: bool = False) -> ApifySchemaBuilder:
"""Add a field specification to the schema."""
self._fields[field.name] = field
if required:
self._required.append(field.name)
return self
def add_string(
self,
name: str,
title: str,
description: str = "",
required: bool = False,
default: str | None = None,
nullable: bool = False,
min_length: int | None = None,
max_length: int | None = None,
pattern: str | None = None,
editor: str | None = None,
) -> ApifySchemaBuilder:
field = StringField(
name,
title,
description=description,
default=default,
nullable=nullable,
min_length=min_length,
max_length=max_length,
pattern=pattern,
editor=editor,
)
return self.add_field(field, required=required)
def add_integer(
self,
name: str,
title: str,
description: str = "",
required: bool = False,
default: int | None = None,
nullable: bool = False,
minimum: int | None = None,
maximum: int | None = None,
) -> ApifySchemaBuilder:
field = IntegerField(
name,
title,
description=description,
default=default,
nullable=nullable,
minimum=minimum,
maximum=maximum,
)
return self.add_field(field, required=required)
def add_number(
self,
name: str,
title: str,
description: str = "",
required: bool = False,
default: float | None = None,
nullable: bool = False,
minimum: float | None = None,
maximum: float | None = None,
) -> ApifySchemaBuilder:
field = NumberField(
name,
title,
description=description,
default=default,
nullable=nullable,
minimum=minimum,
maximum=maximum,
)
return self.add_field(field, required=required)
def add_boolean(
self,
name: str,
title: str,
description: str = "",
required: bool = False,
default: bool | None = None,
nullable: bool = False,
) -> ApifySchemaBuilder:
field = BooleanField(
name, title, description=description, default=default, nullable=nullable
)
return self.add_field(field, required=required)
def add_enum(
self,
name: str,
title: str,
enum: list[str],
description: str = "",
required: bool = False,
default: str | None = None,
nullable: bool = False,
) -> ApifySchemaBuilder:
field = EnumField(
name, title, enum, description=description, default=default, nullable=nullable
)
return self.add_field(field, required=required)
def add_array(
self,
name: str,
title: str,
items: dict[str, Any],
description: str = "",
required: bool = False,
default: list[Any] | None = None,
nullable: bool = False,
min_items: int | None = None,
max_items: int | None = None,
) -> ApifySchemaBuilder:
field = ArrayField(
name,
title,
items,
description=description,
default=default,
nullable=nullable,
min_items=min_items,
max_items=max_items,
)
return self.add_field(field, required=required)
def add_object(
self,
name: str,
title: str,
properties: dict[str, Any],
description: str = "",
required: bool = False,
default: dict[str, Any] | None = None,
nullable: bool = False,
nested_required: list[str] | None = None,
) -> ApifySchemaBuilder:
field = ObjectField(
name,
title,
properties,
description=description,
default=default,
nullable=nullable,
required=nested_required,
)
return self.add_field(field, required=required)
def set_prefab(self, prefab: str) -> ApifySchemaBuilder:
"""Set an Apify input schema prefab (e.g., 'START_URLS')."""
self._prefab = prefab
return self
def build_input_schema(self) -> dict[str, Any]:
"""Build an Apify INPUT_SCHEMA.json-compatible dict."""
schema: dict[str, Any] = {
"title": self.title,
"type": "object",
"schemaVersion": self.schema_version,
"properties": {name: field.to_dict() for name, field in self._fields.items()},
}
if self.description:
schema["description"] = self.description
if self._required:
schema["required"] = self._required
if self._prefab:
schema["prefab"] = self._prefab
return schema
def build_output_schema(self) -> dict[str, Any]:
"""Build an Apify OUTPUT_SCHEMA.json-compatible dict."""
schema: dict[str, Any] = {
"title": self.title,
"type": "object",
"schemaVersion": self.schema_version,
"properties": {name: field.to_dict() for name, field in self._fields.items()},
}
if self.description:
schema["description"] = self.description
if self._required:
schema["required"] = self._required
return schema