How to fix this circular import error in Python

Viewed 255

I have build a Python wrapper for a REST API that currently returns the raw JSON data. My next step is to convert the JSON data into Python objects. For this reason, I've made a Resource class with all the necessary methods to convert a dict into the relevant object.

How do I fix this circular import error? I have thought of removing the periods field from the Calendar class, but them I'm limiting the functionality of the API.

# test.py
from my_project.endpoints import Calendars
from my_project.resources import Calendar

calendars = Calendars(username, password).list()
calendars = [Calendar(**calendar) for calendar in calendars]

# ImportError: cannot import name 'Calendar' from partially initialized module 'my_project.model.resources.calendar' (most likely due to a circular import)
# my_project/client.py
from dataclasses import dataclass, fields

@dataclass
class Resource:
    def __post_init__(self, *_):
        for f in fields(self):
            value = getattr(self, f.name)
            if f.type is list and value is not None:
                astype = f.metadata.get("type", str)
                setattr(self, f.name, [self.__cast(v, astype) for v in value])
            else:
                setattr(self, f.name, self.__cast(value, f.type))

    @classmethod
    def __cast(cls, value, astype):
        if value is None:
            return None
        if astype is datetime:
            return datetime.fromisoformat(value)
        elif astype is date:
            return datetime.fromisoformat(value).date()
        elif astype == cls.__name__:
            key = cls._key()
            return cls(**{key: value})
        elif issubclass(astype, Resource) and isinstance(value, dict):
            return astype(**value)
        elif issubclass(astype, Resource) and isinstance(value, (str, int)):
            return astype(**{astype._key(): value})
        else:
            return astype(value)

    @classmethod
    def _key(cls) -> tuple:
        for f in fields(cls):
            if f.metadata.get("key"):
                return f.name
# my_project/resources.py
from my_project.model.resources.calendar import Calendar
from my_project.model.resources.period import Period
from my_project.model.resources.periodType import PeriodType

__all__ = ["Calendar", "Period", "PeriodType"]
# my_project/model/resources/calender.py
from my_project.model.resources.period import Period

@dataclass
class Calendar(Resource):
    calenderSeq: str = field(default=None)
    name: str = field(default=None)
    description: str = field(default=None)
    periods: list = field(default=None, metadata={"type": Period})
# my_project/model/resources/period.py
from my_project.model.resources.calendar import Calendar
from my_project.model.resources.periodType import PeriodType

@dataclass
class Period(Resource):
    periodSeq: str = field(default=None)
    name: str = field(default=None)
    description: str = field(default=None)
    periodType: PeriodType = field(default=None)
    calendar: Calendar = field(default=None)

Edit As per the comment of Davis (thanks for the suggestion), I tried combining all the files into a single resources.py. Despite the from __future__ import annotations, still got the error NameError: name 'Period' is not defined.

# my_project/resources.py
from __future__ import annotations

from dataclasses import InitVar, dataclass, field
from datetime import date, datetime

from sapcommissions import Resource


@dataclass
class Calendar(Resource):
    calenderSeq: str = field(default=None)
    name: str = field(default=None)
    description: str = field(default=None)
    periods: list = field(default=None, metadata={"type": Period}) # error here


@dataclass
class Period(Resource):
    periodSeq: str = field(default=None)
    name: str = field(default=None)
    description: str = field(default=None)
    periodType: PeriodType = field(default=None)
    calendar: Calendar = field(default=None)


@dataclass
class PeriodType(Resource):
    periodTypeSeq: str = field(default=None)
    name: str = field(default=None)
    description: str = field(default=None)
    level: int = field(default=None)
1 Answers

We need from __future__ import annotations to postpone evaluation of annotations and to support forward references in annotations.

But this makes field's type a string rather than a type in dataclass. That's dataclasses.Field.type becomes name of it's type instead of the type itself.

To fix this, we have to eval the field's type to get it's type.

And because eval requires access to objects which it evaluates and also to make things simpler, I've integrated everything into a single module.

I check if a field's type is str in __post_init__ then only eval it to avoid evaling a type if future versions of Python changes behavior. This is for Python 3.8.

from __future__ import annotations
from dataclasses import dataclass, fields, field
from datetime import datetime, date


@dataclass
class Resource:
    def __post_init__(self, *_):
        for f in fields(self):
            value = getattr(self, f.name)
            field_type = f.type
            if isinstance(f.type, str):
                field_type = eval(f.type)
            # if f.type is list and value is not None:
            if field_type is list and value is not None:
                astype = f.metadata.get("type", str)
                setattr(self, f.name, [self.__cast(v, astype) for v in value])
            else:
                setattr(self, f.name, self.__cast(value, field_type))
                # setattr(self, f.name, self.__cast(value, f.type))

    @classmethod
    def __cast(cls, value, astype):
        if value is None:
            return None
        if astype is datetime:
            return datetime.fromisoformat(value)
        elif astype is date:
            return datetime.fromisoformat(value).date()
        elif astype == cls.__name__:
            key = cls._key()
            return cls(**{key: value})
        elif issubclass(astype, Resource) and isinstance(value, dict):
            return astype(**value)
        elif issubclass(astype, Resource) and isinstance(value, (str, int)):
            return astype(**{astype._key(): value})
        else:
            return astype(value)

    @classmethod
    def _key(cls) -> tuple:
        for f in fields(cls):
            if f.metadata.get("key"):
                return f.name


@dataclass
class Period(Resource):
    periodSeq: str = field(default=None)
    name: str = field(default=None)
    description: str = field(default=None)
    periodType: PeriodType = field(default=None)
    calendar: Calendar = field(default=None)


@dataclass
class Calendar(Resource):
    calenderSeq: str = field(default=None)
    name: str = field(default=None)
    description: str = field(default=None)
    periods: list = field(default=None, metadata={"type": Period})  # error here


@dataclass
class PeriodType(Resource):
    periodTypeSeq: str = field(default=None)
    name: str = field(default=None)
    description: str = field(default=None)
    level: int = field(default=None)
Related