Select to view content in your preferred language

cimple: A script that flattens and demystifies cim

407
3
a week ago
Status: Open
HaydenWelch
MVP Regular Contributor

After a year of procrastination, I've finally written a script that makes the CIM module sane:

https://github.com/hwelch-fle/cimple

 

The goal here is to generate a new cim module from the existing one with the added benefit of proper typing of dependent attributes and string literal completion. The overall structure and usability of the CIM is also improved by utilizing dataclasses and removing the original inheritance hierarchy in favor of direct attribute definition.

Example:

# Original CIM definition
class CIMChart():
    """
      Provides access to members that control chart properties.
    """
    def __init__(self, *args, **Kwargs):
        self.name = str()
        self.series = []
        self.generalProperties = 'CIMChartGeneralProperties'
        self.legend = 'CIMChartLegend'
        self.axes = []
        self.mapSelectionHandling = ChartMapSelectionHandling.Highlight
        self.metaData = str()
        self.multiSeriesChartProperties = 'CIMMultiSeriesChartProperties'
        self.enableServerSideProcessing = False
        self.chartType = ChartType.Basic

# cimple CIM definition
@dataclass
class CIMChart:
    """https://github.com/Esri/cim-spec/blob/main/docs/v3/CIMCharts.md#cimchart-1

    Provides access to members that control chart properties.
    """
    name: str = ''
    series: list[Any] = dc_field(default_factory=list[Any])
    generalProperties: CIMChartGeneralProperties = dc_field(default_factory=lambda: CIMChartGeneralProperties())
    legend: CIMChartLegend = dc_field(default_factory=lambda: CIMChartLegend())
    axes: list[Any] = dc_field(default_factory=list[Any])
    mapSelectionHandling: ChartMapSelectionHandling = 'Highlight'
    metaData: str = ''
    multiSeriesChartProperties: CIMMultiSeriesChartProperties = dc_field(default_factory=lambda: CIMMultiSeriesChartProperties())
    enableServerSideProcessing: bool = False
    chartType: ChartType = 'Basic'

 

The dc_field factories replace the GetPythonClass call and allow for mutable defaults. There are still some lists and dictionaries that use the Any type since I couldn't infer their type from the original module.

 

Update: 

I would like to pose a question to the community:

When using CIM, do you care about inheritance hierarchy? As in, do you use isinstance checks on the bases of a desired CIM class?

e.g.

x = obj.getDefinition('V3')

if issubclass(x, cim.CIMBasicFeatureLayer):
    ## do thing if Layer type
    ...
elif issubclass(x, cim.CIMDisplayTable):
    ## do thing if Table type
    ...

 

Or do you mostly just target the expected output class

e.g.

x = obj.getDefinition('V3')

if isinstance(x, cim.CIMFeatureLayer):
    ## do thing if Layer instance
    ...
elif isinstance(x, cim.CIMMapTableView):
    ## do thing if Table instance
    ...

 

Currently, this module removes all inheritance from CIM and treats each class as a distinct set of keys. This means keys are duplicated between classes, but I feel that when writing code this is much easier to read as you don't need to traverse a whole hierarchy when referencing the source:

HaydenWelch_0-1767376427979.png

Original CIM with inheritance on right, flat cimple.cim on left

If I was to re-introduce subclass and instance checks, I would likely do so by comparing the keys of the classes:

def __subclasscheck__(self, other: object) -> bool:
    if hasattr(other, '__dataclass_fields__'):
        return set(type(other).__dataclass_fields__).issubset(set(type(self).__dataclass_fields__))
    return super().__subclasscheck__(other)

 

Since most of the time when you're type narrowing CIM, you're really just looking for a specific set of keys, I think this is the most sensible solution. It will likely diverge from the base CIM inheritance model though since simple classes that have only simple attributes like 'name' and 'type' will now be considered to be related.

 

Alternatively, I could re-write the script to honor the inheritance hierarchy, with the drawback that inheritance may cause some weird issues with circular imports (the reason the cim module uses strings is to defer this to runtime). Since right now no classes inherit from any other cim class, it is safe to defer the typing and defaults to runtime where all classes are loaded in and can be initialized.

 

Update 2:

I've gone ahead and implemented a CIMBase metaclass that lives in its own _base.py file:

class CIMBase(type):
    def __subclasscheck__(cls, subclass: type) -> bool:
        return set(getattr(cls, '__dataclass_fields__', 'a')).issubset(set(getattr(subclass, '__dataclass_fields__', 'b')))
    
    def __instancecheck__(self, instance: object) -> bool:
        return issubclass(type(instance), self)

 

All CIM classes will now inherit this metaclass to get attribute based (duck style) instance/subclass checks:

@dataclass
class CIMFeatureLayer(metaclass=CIMBase):
    """https://github.com/Esri/cim-spec/blob/main/docs/v3/CIMVectorLayers.md#cimfeaturelayer-1

    NO DOC
    """
    ...
    
@dataclass
class CIMGeoFeatureLayerBase(metaclass=CIMBase):
    """https://github.com/Esri/cim-spec/blob/main/docs/v3/CIMVectorLayers.md#cimgeofeaturelayerbase-1

    Represents a layer that draws geographic feature data using a renderer.
    """
    ...
    
>>> isinstance(CIMFeatureLayer(), CIMGeoFeatureLayerBase)
True
>>> issubclass(CIMFeatureLayer, CIMGeoFeatureLayerBase)
True

 

Again, this will not reveal the original class hierarchy, but will instead reveal if the checked class (arg 1) has all the attributes defined in arg 2.

 

Update 3:

I've now implemented cimple.conversion that allows conversion between cim, cimple, and json. There are some weird caveats for testing (cim objects can take a string literal or an enumerated integer, so writing a test for that required checking against the enum name attribute). Here's the simple code for the conversion functions:

def cim_to_json(cim_object: object, indent: int=4) -> str:
    """Convert a CIM object into a JSON string"""
    return json.dumps(cim_object, indent=indent, cls=JSONCIMEncoder)

def json_to_cim(cim_json: str) -> Any:
    """Convert a json string into an initialized CIM object"""    
    return json.loads(cim_json, cls=JSONCIMDecoder)

def cim_to_cimple(cim_obj: Any) -> Any:
    if isinstance(cim_obj, list):
        return [cim_to_cimple(o) for o in cim_obj]
    cimple_obj = getattr(cim, cim_obj.__class__.__name__, None)
    if cimple_obj:
        return cimple_obj(**{k: cim_to_cimple(v) for k, v in cim_obj.__dict__.items()})
    return cim_obj

def cimple_to_cim(cimple_obj: Any) -> Any:
    if isinstance(cimple_obj, list):
        return [cimple_to_cim(o) for o in cimple_obj]
    cim_obj = getattr(arcpy_cim, cimple_obj.__class__.__name__, None)
    if cim_obj:
        # CIM objects from the arcpy.cim module cannot be initialized with values
        # We need to initialize the object then update the instance __dict__
        cim_obj = cim_obj()
        cim_obj.__dict__.update({k: cimple_to_cim(v) for k, v in cimple_obj.__dict__.items()})
        return cim_obj
    return cimple_obj

 

I've excluded the JSONCIMDecoder an JSONCIMEncoder classes, but they are exposed in the module root if you need to subclass them for custom encodings. 

3 Comments
VenkataKondepati

This is a great idea. Thanks for sharing.

Replacing the current CIM’s string-heavy, inheritance-based model with dataclasses and proper typing makes a huge difference in usability, IDE autocomplete, and code safety. The default_factory approach alone removes a lot of hidden foot-guns.

Even with some remaining Any types, this already makes CIM far more usable for real development. With a bit of versioning clarity and round-trip validation, this could easily become the preferred way to work with CIM in Pro.

HaydenWelch

@VenkataKondepati Please test it out of you have the chance. I threw this script together in a day so I'm sure there will be some issues and I'd love to have your input.

 

My goal is to basically create a better cim that's both backwards compatible and also built on the old so they can just add this to their CI pipeline for releases. Maybe just hiding this new cim in a cim._types submodule?

This is also a decent test of the cim since it initializes all classes to infer their attributes and types. I found one class definition that was invalid which would absolutely tick me off if I needed to use it (see my post in Python Questions)

VenkataKondepati

Hi @HaydenWelch,

I am waiting for ArcGIS Pro license since my new company is still trying to become an ESRI partner.

I will test it in next couple of weeks.

Wish you a Happy New Year!!!

Regards,
Venkat