Skip to main content

Simplifies defining schema for and managing JSON objects with non-JSON types

Project description

SprelfJSON - JSONModel

JSONModel simplifies the process of working with JSON data in Python by allowing you to define the structure of your JSON objects using class annotations. It provides robust parsing (from_json()) and dumping (to_json()) capabilities, handling a variety of data types and supporting nested and polymorphic JSON structures.

Features:

  • Define JSON structure using Python class annotations.
  • Automatic parsing of JSON data into Python objects.
  • Automatic dumping of Python objects into JSON-compatible dictionaries.
  • Support for standard Python types (string, int, float, bool, list, dict, etc.).
  • Handling of additional types like datetime, date, time, timedelta, bytes, re.Pattern, Enum,IntEnum, StrEnum, and IntFlag.
  • Flexibility to add support for additional data types by subclassing ModelType.
  • Seamless handling of nested JSONModel objects.
  • Dynamic parsing of JSONModel subclasses based on a designated field.
  • Ability to define alternate parsing and dumping logic.
  • Clear error reporting for validation and parsing issues.

Installation

pip install sprelf-json

If you want to include YAML support:

pip install sprelf-json[yaml]

Basic Usage

Define a simple JSON structure:

from SprelfJSON import JSONModel

class User(JSONModel):
    name: str
    age: int
    is_active: bool = True # Field with a default value

Parse JSON data into a User object:

json_data = {"name": "Alice", "age": 30}
user = User.from_json(json_data)

print(user.name)       # Output: Alice
print(user.age)        # Output: 30
print(user.is_active)  # Output: True (default value)

# JSON data can include the default value explicitly
json_data_with_default = {"name": "Bob", "age": 25, "is_active": False}
user_explicit = User.from_json(json_data_with_default)
print(user_explicit.is_active) # Output: False

Dump a User object back into JSON data:

user_to_dump = User(name="Charlie", age=40, is_active=False)
dumped_data = User.to_json()

print(dumped_data) # Output: {'name': 'Charlie', 'age': 40, 'is_active': False}

# Default values are not included by default unless specified in the class
user_with_default = User(name="David", age=35)
dumped_data_default = user_with_default.to_json()
print(dumped_data_default) # Output: {'name': 'David', 'age': 35} (is_active is omitted)

Defining Models

Define a JSON model by creating a class that inherits from JSONModel and using type annotations for the expected fields.

from __future__ import annotations

from typing import Optional, Union
from SprelfJSON import JSONModel, ModelElem
import datetime

class Product(JSONModel):
    id: int
    name: str
    price: float
    tags: list[str] # List of strings
    attributes: dict[str, str] # Dictionary with string keys and string values
    description: Optional[str] = None # Optional field, can be None
    created_at: datetime.datetime # Using a complex type

Fields with Default Values

Provide any default values directly in the class definition:

class Settings(JSONModel):
    theme: str = "dark"
    notifications_enabled: bool = True

For mutable default values (like lists or dictionaries), use ModelElem with default_factory:

class UserProfile(JSONModel):
    username: str
    favorite_numbers: ModelElem(list[int], default_factory=list) # Use default_factory for mutable defaults

Handling Different Types

This library handles a variety of common datatypes, converting them to and from native JSON types when dumping and parsing.

JSON Native Types

str, int, float, bool, None are parsed and dumped directly.

Complex Types

datetime.datetime, datetime.date, datetime.time, datetime.timedelta, bytes, re.Pattern are automatically parsed and dumped to/from appropriate JSON representations (e.g., strings for dates/times, base64 for bytes, string for patterns).

import datetime
import re

class Event(JSONModel):
    start_time: datetime.datetime
    duration: datetime.timedelta
    event_id: bytes
    pattern: re.Pattern

# Example usage
json_event = {
    "start_time": "2023-10-27T14:00:00.000Z",
    "duration": 3600000, # timedelta in milliseconds
    "event_id": "YWJjMTIz", # base64 encoded bytes
    "pattern": "^[A-Z]+$"
}
event = Event.from_json(json_event)

print(type(event.start_time).__name__)  # Output: "datetime"
print(type(event.duration).__name__)    # Output: "timedelta"
print(type(event.event_id).__name__)    # Output: "bytes"
print(type(event.pattern).__name__)     # Output: "Pattern"

dumped_event = event.to_json()
print(dumped_event)     # Output: The original JSON

Enums

enum.Enum, enum.IntEnum, enum.StrEnum, and enum.IntFlag are supported. Plain Enums are dumped by name, the others by value. They can be parsed as either.

import enum

class Status(enum.Enum):
    PENDING = "pending"
    PROCESSING = "processing"
    COMPLETED = "completed"

class ErrorCode(enum.IntEnum):
    NOT_FOUND = 404
    INTERNAL_ERROR = 500

class Flags(enum.IntFlag):
    FLAG_A = 1
    FLAG_B = 2
    FLAG_C = 4

class Task(JSONModel):
    status: Status
    error_code: Optional[ErrorCode] = None
    flags: Flags

# Example usage
json_task = {
    "status": "PROCESSING",
    "flags": 3 # FLAG_A | FLAG_B
}
task = Task.from_json(json_task)

print(task.status)      # Output: Status.PROCESSING
print(task.flags)       # Output: Flags.FLAG_A | Flags.FLAG_B

dumped_task = task.to_json()
print(dumped_task)      # Output: {'status': 'PROCESSING', 'flags': 3}

Generic Types

list, dict, set, frozenset, tuple, type, Iterable, Iterator, Generator, Sequence, MutableSequence, Set, MutableSet, Mapping, MutableMapping, Collection, Union, and Optional are supported.

The following classes maintain any lazy properties when parsing (deferring validation until iterated): Iterable, Iterator, Generator

from __future__ import annotations
from typing import Union, Optional
from SprelfJSON import JSONModel

class DataContainer(JSONModel):
    items: list[int]
    settings: dict[str, bool]
    value: Union[str, int]
    optional_items: Optional[list[float]]
    a_type: type[JSONModel]
    coords: tuple[float, float]
    tags: set[str]

# Example usage
json_data = {
    "items": [1, 2, 3],
    "settings": {"enabled": True, "visible": False},
    "value": "hello",
    "optional_items": [1.1, 2.2],
    "a_type": "SomeJSONModelType",
    "coords": [10.5, 20.1], # JSON array is parsed as tuple
    "tags": ["tag1", "tag2", "tag1"] # JSON array is parsed as set
}
container = DataContainer.from_json(json_data)

print(type(container.items).__name__)           # Output: "list"
print(type(container.settings).__name__)        # Output: "dict"
print(type(container.value).__name__)           # Output: "str"
print(type(container.optional_items).__name__)  # Output: "list"
print(type(container.a_type).__name__)          # Output: "type"
print(type(container.coords).__name__)          # Output: "tuple"
print(type(container.tags).__name__)            # Output: "set"

dumped_data = container.to_json()
print(dumped_data) # Note: sets and tuples are dumped as JSON arrays (lists)

Nested Models

You can nest JSONModel definitions within other JSONModels.

class Address(JSONModel):
    street: str
    city: str
    zip_code: str

class Order(JSONModel):
    order_num: int

class Customer(JSONModel):
    customer_id: int
    name: str
    shipping_address: Address
    past_orders: list[Order]

Dynamic Subclass Parsing

JSONModel can automatically determine and instantiate the correct subclass based on a specified field in the JSON data.

Define a base class and subclasses:

class Shape(JSONModel):
    # Base class - often abstract, but can have common fields
    __name_field__ = "type" # Field to check for subclass name
    __name_field_required__ = True # Require the type field

    color: str

class Circle(Shape):
    radius: float
    
    @classmethod
    def model_identity(cls) -> str:
        return "circle" # Value in the 'type' field for this subclass

class Square(Shape):
    side_length: float
    # Use default identity for this class (class's name, 'Square')

# You can then have a model containing a list of shapes
class Drawing(JSONModel):
    shapes: list[Shape] # List can contain Circle or Square objects

Parse JSON containing different shape types:

json_drawing = {
    "shapes": [
        {"type": "circle", "color": "red", "radius": 10.0},
        {"type": "Square", "color": "blue", "side_length": 5.0},
        {"type": "circle", "color": "green", "radius": 2.5}
    ]
}

drawing = Drawing.from_json(json_drawing)

for shape in drawing.shapes:
    print(f"Shape color: {shape.color}")
    if isinstance(shape, Circle):
        print(f"Circle radius: {shape.radius}")
    elif isinstance(shape, Square):
        print(f"Square side length: {shape.side_length}")

#  Output -
# Shape color: red
# Circle radius: 10.0
# Shape color: blue
# Square side length: 5.0
# Shape color: green
# Circle radius: 2.5

By default:

  • The name field (specified by __name_field__) is "__name"
  • The name field is required
  • The model identity for a class (the matching value to find in this name field) is the name of the class (ie. cls.__name__)

See "Extra Options" section for more details.

Support for Additional Types

To extend the supported types, create a new class that is a subclass of ModelType, implementing the required methods.

class ModelType_PatternExample(ModelType):

    # This is used to test whether this model type applies to the given model element.
    # The first ModelType to return True here is the one used.
    @classmethod
    def test_origin(cls, elem: _BaseModelElem, **kwargs) -> bool:
        return elem.origin == re.Pattern
    
    # This is used for validating if a value in a particular field meets the criteria for this type
    @classmethod
    def is_valid(cls, val: Any, elem: _BaseModelElem, **kwargs) -> bool:
        return isinstance(val, elem.origin)

    # This is used for parsing a value to the desired type; usually is given a JSON value
    @classmethod
    def parse(cls, val: Any, elem: _BaseModelElem, **kwargs) -> type[SupportedUnion]:
        if isinstance(val, re.Pattern):
            return val
        if isinstance(val, str):
            return re.compile(val)
        raise ModelElemError(elem, "Woops, can't parse this!")

    # This is used to dump the value into a JSON-compatible type
    @classmethod
    def dump(cls, val: Any, elem: _BaseModelElem, **kwargs) -> JSONType:
        definitely_a_pattern_now = elem.parse_value(val, **kwargs)
        return definitely_a_pattern_now.pattern  # This is a str

Note:

A list of all ModelType subclasses is cached the first time any JSONModel object is dumped, parsed, or validated. As long as your subclass is defined before this, it will be automatically included. If you need further manipulation of the allowed types, see _AliasedModelTypes and _ConcreteModelTypes on ModelElem

Alternate Parsing and Dumping

Use AlternateModelElem within a ModelElem definition to specify alternative ways to parse incoming data or dump outgoing data. These objects are defined like ModelElem, but expect to find a different type, and define a function to convert from that type to the original type that the ModelElem expects.

When parsing or dumping, it will first attempt to operate in its native type. Only if that fails, it will then attempt doing so with each of the defined alternate definitions. To override this behavior and forcibly use these alternates, you may provide the use_alternates_only parameter.

from SprelfJSON import JSONModel, ModelElem, AlternateModelElem

class DataItem(JSONModel):
    # Can parse an integer from a string
    count: ModelElem(int, alternates=[AlternateModelElem(str, int)])

    # Can dump a boolean as a string "true" or "false"
    is_valid: ModelElem(bool, alternates=[AlternateModelElem(str, lambda s: s.lower() == "true", jsonifier=lambda b: str(b).lower())],
                        use_alternates_only=True)

# Example usage
json_data = {
    "count": "50", # Input is string
    "is_valid": "True" # Input is string
}
item = DataItem.from_json(json_data)

print(item.count)     # Output: 50 (parsed as int)
print(item.is_valid)  # Output: True (parsed as bool)

dumped_data = item.to_json()
print(dumped_data)  # Output: {'count': 50, 'is_valid': "true"} # 'count' dumped as int, 'is_valid' dumped as string

Error Handling

JSONModel uses JSONModelError and a subclass ModelElemError to indicate issues during parsing, validation, or dumping.

from SprelfJSON import JSONModel, JSONModelError

class StrictModel(JSONModel):
    required_field: str
    int_field: int

# Example of missing required field error
json_missing = {"int_field": 123}
try:
    StrictModel.from_json(json_missing)
except JSONModelError as e:
    print(f"Caught expected error: {e}") # Output: Caught expected error: Missing required key 'required_field' on 'StrictModel'.

# Example of invalid type error
json_invalid_type = {"required_field": "hello", "int_field": "not an int"}
try:
    StrictModel.from_json(json_invalid_type)
except JSONModelError as e:
    print(f"Caught expected error: {e}") # Output: Caught expected error: Model error on key 'int_field' of 'StrictModel': Schema mismatch: Expected type '<class 'int'>', but got 'str' instead

# Example of extra field error (by default)
json_extra_field = {"required_field": "hello", "int_field": 123, "extra": "data"}
try:
    StrictModel.from_json(json_extra_field)
except JSONModelError as e:
    print(f"Caught expected error: {e}") # Output: Caught expected error: The following keys are not found in the model for 'StrictModel': extra

Extra Options

There are some class-level options in JSONModel to define certain types of behavior by the class it's applied to and all subclasses.

  • __name_field__: str: When parsing, the name of the JSON field that stores the name of the JSONModel object to dynamically parse. Defaults as "__name"
  • __name_field_required__: bool: When parsing, will reject any JSON objects that do not have the name field defined. Defaults as False
  • __include_name_in_json_output__: bool: When dumping, whether to include the name field in the output. Defaults as False
  • __allow_null_json_output__: bool: When dumping, whether to allow null JSON values. Defaults as False
  • __include_defaults_in_json_output__: bool: When dumping, whether to include fields whose values are equal to the default value. Defaults as False.
  • __allow_extra_fields__: bool: When parsing, whether to ignore extra fields that don't belong to the model. If False, then an error is raised if extra fields are found. Defaults as False
  • __exclusions__: list[str]: A list of fields that are defined, but should be ignored for the purposes of parsing/dumping.
  • __eval_context__: dict[str, ...]: A map of modules and classes to include when evaluating the annotations (which are read as strings) into actual types.

There are additional class-level options in ModelElem:

  • __base64_altchars__: tuple[bytes, ...]: A list of 2-character byte strings that define the allowable base64 alternate characters when parsing a string to bytes. The parser will try each one in order until one succeeds. The dumper will always use the first byte string here. By default, is defined as (b"-_", b"+/"), preferring URL-safe altchars.

JSON Annotating and Duck-Typing

JSONDefinitions contains a few helpers for both annotating and for validating JSON data.

For annotating:

from SprelfJSON import JSONType, JSONObject, JSONModel, JSONArray, JSONContainer, JSONValue
def function(arg: JSONObject) -> JSONType:
    ...

class ExampleModel(JSONModel):
    obj: JSONObject
    arr: JSONArray
    val: JSONValue # any JSON-compatible type other than object or array
    container: JSONContainer # array or object
    any: JSONType # any JSON-compatible type

For validating using duck-typing classes:

from __future__ import annotations
from SprelfJSON import JSONObjectLike, JSONLike, JSONArrayLike, JSONContainerLike, JSONValueLike

print(isinstance({"a": 1}, JSONObjectLike)) # Output: True
print(isinstance(1, JSONObjectLike)) # Output: False
print(isinstance([1, 2, 3], JSONArrayLike)) # Output: True
print(isinstance(1, JSONArrayLike)) # Output: False
print(isinstance({"a": 1}, JSONContainerLike)) # Output: True
print(isinstance([1, 2, 3], JSONContainerLike)) # Output: True
print(isinstance(1, JSONValueLike)) # Output: True
print(isinstance("hello", JSONValueLike)) # Output: True
print(isinstance(True, JSONValueLike)) # Output: True
print(isinstance(None, JSONValueLike)) # Output: True
print(isinstance([1, 2, 3], JSONValueLike)) # Output: False
print(isinstance(1, JSONLike)) # Output: True

# Or similarly with subclasses...
print(issubclass(int, JSONValueLike)) # Output: True
print(issubclass(dict[str, int], JSONObjectLike)) # Output: True
print(issubclass(dict[int, int], JSONObjectLike)) # Output: False

Ephemeral Values

The Ephemeral class provides a wrapper for any object, allowing its functionality and identity to be mimicked without exposing its wrapped value to the JSON serialization/deserialization process. This means that objects wrapped in Ephemeral are explicitly not parsed from or dumped to JSON. They exist solely in memory, holding values that are manipulated transiently and then forgotten, making their contents irrelevant to JSON-serializability.

This is intended to be useful for values that are created and used at runtime but should never persist in a serialized form (e.g., database connections, temporary calculations, sensitive runtime data).

Key Characteristics:

  • Wrapper: Encapsulates any Python object.
  • Accessor Exposure: It delegates attribute access (__getattr__, __setattr__, __delattr__) to the wrapped object, making it behave much like the object it contains.
  • In-Memory Only: Ephemeral instances and their wrapped values are never included when a JSONModel is dumped to JSON, nor are they expected when parsing JSON into a JSONModel.
  • Non-JSON-Serializable Contents: Because they don't interact with JSON serialization, the objects wrapped by Ephemeral do not need to be JSON-serializable themselves.

Example Usage:

from SprelfJSON import JSONModel, Ephemeral
import datetime

class MyTransientObject:
    def __init__(self, data):
        self.data = data
    def process(self):
        return f"Processed: {self.data}"

class DataContainer(JSONModel):
    name: str
    # This field will be ignored during parsing from JSON and dumping to JSON.
    runtime_data: Ephemeral[MyTransientObject]
    
    # You can also set a default value for Ephemeral fields.
    # Note that the default value must also be wrapped in Ephemeral.
    current_timestamp: Ephemeral[datetime.datetime] = Ephemeral(datetime.datetime.now())

# Creating an instance with an Ephemeral field
transient_obj = MyTransientObject("some important runtime info")
container = DataContainer(
    name="Report",
    runtime_data=Ephemeral(transient_obj)
)

print(container.name)               # Output: Report
print(container.runtime_data.data)  # Output: some important runtime info
print(container.runtime_data.process()) # Output: Processed: some important runtime info
print(container.current_timestamp.value) # Output: (Current datetime object)

# Dumping to JSON will omit runtime_data and current_timestamp
# even though runtime_data was explicitly provided and current_timestamp has a default.
dumped_json = container.to_json()
print(dumped_json)
# Output: {'name': 'Report'} 

# Parsing from JSON:
# Values provided in JSON for Ephemeral fields will be ignored.
json_input_data = {
    "name": "Another Report",
    "runtime_data": {"data": "this data will be ignored"}, # This field will be ignored during parsing!
}
parsed_container = DataContainer.from_json(json_input_data)
print(parsed_container.name)                  # Output: Another Report
# When parsing from JSON, if the JSON data does not contain a value for an Ephemeral field, 
# the field will be set to None, unless a default is specified.
print(parsed_container.runtime_data)          # Output: None

Utility Methods:

  • Ephemeral.is_ephemeral(obj: Any) -> bool: Checks if an object is an instance of Ephemeral or has the __is_ephemeral__ attribute set to True.
  • Ephemeral.unwrap(o: Ephemeral[T] | T) -> T: Returns the wrapped value if o is an Ephemeral instance, otherwise returns o itself.

Known issues

  • When subclassing a JSONModel subclass that has a default value in a field, IDEs may provide a warning related to "non-default arguments following default arguments". When actually running the code, there is no issue here, so it's safe to ignore or suppress such warnings.

Project details


Download files

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

Source Distribution

sprelf_json-2025.10.27.0.tar.gz (31.5 kB view details)

Uploaded Source

Built Distribution

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

sprelf_json-2025.10.27.0-py3-none-any.whl (27.7 kB view details)

Uploaded Python 3

File details

Details for the file sprelf_json-2025.10.27.0.tar.gz.

File metadata

  • Download URL: sprelf_json-2025.10.27.0.tar.gz
  • Upload date:
  • Size: 31.5 kB
  • Tags: Source
  • Uploaded using Trusted Publishing? Yes
  • Uploaded via: twine/6.1.0 CPython/3.13.7

File hashes

Hashes for sprelf_json-2025.10.27.0.tar.gz
Algorithm Hash digest
SHA256 81f216cc5398ed89f4a06344957f8e5432af95ff855cd22ef0fd9dd7baf0526e
MD5 532c0fd845604e81d6a1d377c1d35f72
BLAKE2b-256 163bb53eab522e8be408fe9ef6e0f51af4b9163addab990e9331b65d77955a88

See more details on using hashes here.

Provenance

The following attestation bundles were made for sprelf_json-2025.10.27.0.tar.gz:

Publisher: publish.yml on cgdilley/SprelfJSON

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

File details

Details for the file sprelf_json-2025.10.27.0-py3-none-any.whl.

File metadata

File hashes

Hashes for sprelf_json-2025.10.27.0-py3-none-any.whl
Algorithm Hash digest
SHA256 a01b5707ebc03704d8f8088d95ac872970cc7ff50da0db2d3d0a5bdafab80e2e
MD5 1e0e24824a2edfc6d86a7230abf21839
BLAKE2b-256 abe6e30e6a5ffd661699e037611defe0cf6840c4d62ba33029f93b58161d54be

See more details on using hashes here.

Provenance

The following attestation bundles were made for sprelf_json-2025.10.27.0-py3-none-any.whl:

Publisher: publish.yml on cgdilley/SprelfJSON

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

Supported by

AWS Cloud computing and Security Sponsor Datadog Monitoring Depot Continuous Integration Fastly CDN Google Download Analytics Pingdom Monitoring Sentry Error logging StatusPage Status page