modelx v0.32.0 (8 August 2026)#
This release introduces the following enhancements, backward-incompatible changes and bug fixes.
To update modelx to the latest version, use the following command:
>>> pip install modelx --upgrade
Anaconda users should use the conda command instead:
>>> conda update modelx
Enhancements#
IO specs are saved as readable declarations
This release introduces a new version of the model serializer, version 8,
which is now the default format written by write_model() and
zip_model() (GH266).
Until version 7, the IO specs of a model, i.e. the objects created by
Model.new_pandas,
Model.new_excel_range and
Model.new_module and their
UserSpace counterparts, were pickled — into a
binary _data/iospecs.pickle file since version 5. Version 8 declares
them instead as plain literal tuples in a text file, _data/iospecs.py:
# modelx: iospecs
# (key, class, version, path, io_args, spec_args)
(1, 'PandasData', 1, 'files/data.csv', {'file_type': 'csv'}, {'sheet': None, ...})
and the References that point at them carry a comment naming the spec:
df = ("IOSpec", 1) # PandasData path='files/data.csv' file_type='csv'
_data/data.pickle, which holds the input values of the model, is now
the only pickle left in a saved model. How a model reads its external
files is therefore reviewable and diffable in version control, and it no
longer depends on modelx’s internal module layout, because spec classes
are now saved by their bare class names.
When a version-8 model is read, each declaration line is restored
independently. An unparsable line, an invalid or duplicated key, an
unknown spec class or an unreadable IO file emits a UserWarning and
skips only that line, letting the rest of the model load. References to a
spec that could not be restored are set to None.
Byte-stable saved models
Saved models no longer embed CPython memory addresses, and the order in which they are written is now derived from the model itself instead of from the history of the session (GH265). As a result, saving a model, reading it back and saving it again produces byte-identical file contents, across processes and across runs, except for values whose own pickled form depends on the process hash seed, such as sets of strings.
For models saved as directories by write_model(), the tree
is byte-identical, so committed models stop showing up as spurious
diffs in version control. For models saved as zip archives by
zip_model(), every archive entry is byte-identical, but the
archive file itself is not, because zip entry headers record the
wall-clock time of the save. Excel workbooks written for IO specs are the
one exception: they are regenerated on every save and embed their own
internal timestamps, so they still differ from save to save.
This applies to serializer versions 6, 7 and 8. The on-disk format of
versions 6 and 7 is unchanged, so earlier versions of modelx still read
what this release writes with version=6 or version=7.
Tolerant model loading
read_model() no longer aborts the entire load when a value
saved in a model cannot be unpickled in the current environment, which
typically happens when the model was saved with a different version of a
third-party package such as pandas (GH255).
Values that cannot be restored are now skipped and the rest of the model
loads normally. A Reference whose value is lost is set to None;
unrestorable Cells input values and ItemSpace input values are skipped so
that they are recomputed by their formulas. A summary warning for the
model as a whole is issued first, followed by a UserWarning for each
lost value naming the object it belonged to.
The tolerant path only runs after a normal load has failed, so successful loads are not slowed down. It covers serializer versions 4 and later.
Selective ItemSpace invalidation
Previously, a structural edit to a Space that any live
ItemSpace depended on — creating or deleting
a Cells or a Reference, or adding, removing or deleting a Space —
deleted all the ItemSpaces of every parameterized Space using the
edited Space, including ItemSpaces built on other, unaffected base
Spaces.
ItemSpace deletion is now selective per ItemSpace: an edit (creating or deleting a Reference or changing its value, creating, deleting or renaming a Cells, changing a formula, or adding, removing, renaming or deleting a Space) deletes an ItemSpace only when the edited Space can actually affect it, namely when the edited Space is one of:
a Space that a node of the ItemSpace’s dynamic tree is based on,
a base Space of such a Space, or
the nearest static Space containing the ItemSpace — the parameterized Space itself in the normal case, or its nearest static ancestor when the parameterized Space is itself dynamic (nested ItemSpace trees).
Changing a Cells formula or renaming a Cells deletes only the ItemSpaces whose dynamic trees are based on the Cells’ Space (or on a Space inheriting the Cells), as before.
ItemSpaces built on unrelated Spaces — including sibling ItemSpaces of the same parameterized Space built on other base Spaces — now survive such edits, which avoids costly recalculation of unaffected dynamic subtrees in large models.
Assigning or deleting a model-level (global) Reference still deletes all ItemSpaces in the model.
Failed edits leave the model unchanged
Model edits now run as transactions that are rolled back when they fail. In particular:
A failed
UserSpace.add_basesno longer leaves phantom derived Cells and partially inherited References behind in the sub Spaces.UserSpace.new_cellsanddefcells()called with an unparsable formula no longer leave a half-built Cells in the Space. Previously theSyntaxErrorwas raised but the Space was corrupted, and the next edit on it failed with anAttributeError.
Clearer error for unknown serializer versions
read_model() now raises
ValueError: unsupported serializer version: ...; the model may have
been saved by a newer version of modelx instead of letting a raw
ModuleNotFoundError escape when it meets a model saved in a format
it does not know. The same applies to an unknown version passed to
write_model() and zip_model().
Backward Incompatible Changes#
This release introduces a new version of the model serializer (serializer version 8). Models saved by this version of modelx cannot be read by previous versions of modelx. To save a model in a format that modelx v0.31.x can read, pass
version=7towrite_model()orzip_model():>>> modelx.write_model(model, "model_dir", version=7)
Serializer version 7 was itself introduced by modelx v0.31.0, so to save a model for modelx v0.22.0 through v0.30.x, pass
version=6instead.Because serializer version 8 declares IO specs as literals instead of pickling them, every IO spec parameter must be representable as a Python literal, and every IO spec class must be one of
PandasData,ExcelRangeandModuleData. A model that does not satisfy this now raisesTypeErrorfromwrite_model()orzip_model(). In practice this affectsModel.new_pandasandUserSpace.new_pandascalled with aSerieswhosenamecannot be written as a Python literal — apandas.Timestamp-named Series, for example, or afloatname that isNaNor infinite. Names that are numpy scalars are converted to their plain Python equivalents and are unaffected, and so are tuples of literals, such as the name of a Series taken from a column of aDataFramewithMultiIndexcolumns.Such models can still be saved by passing
version=7. The check runs before the previous save is rotated into its_BAKbackup and before any file is written, so a save that fails this way leaves the previous save untouched.As described above, edits that previously deleted all ItemSpaces of the affected parameterized Spaces now delete only the dependent ItemSpaces. Code that relied on unrelated ItemSpaces being implicitly deleted by an edit should delete them explicitly, for example with
UserSpace.clear_itemsorModel.clear_all.Deleting a Space now deletes the live ItemSpaces of that Space and of its child Spaces, which previously survived the deletion with dangling references to the deleted Space. Code holding such an ItemSpace and using it after its Space is deleted now raises
DeletedObjectErrorinstead of silently returning stale values.UserSpace.add_basesnow raisesNameErrorwhen the Space or one of its sub Spaces has a child Space with the same name as a Cells or a Reference it would inherit. Previously the operation silently succeeded, leaving the Space with two members under one name: an inherited Cells shadowed the child Space, while an inherited Reference was itself shadowed by the child Space on attribute access but shadowed it inside formulas. Becauseread_model()replays base relationships throughadd_bases, a model saved by an earlier version of modelx whose child Space collides with an inherited Cells now raises the sameNameErrorwhen read; rename the colliding member with an older version of modelx before upgrading. (A child Space colliding with an inherited Reference already failed to load withValueError: Cannot create reference '...'in earlier versions of modelx.)Deleting a derived Reference now raises
ValueErrorin all cases. When the Reference held a modelx object, the deletion was previously a silent no-op.
Bug Fixes#
Renaming a Cells now refreshes the namespace of its Space, so formulas referring to the old name fail with a
FormulaErrorreportingNameError: name '...' is not definedinstead of silently resolving to the renamed Cells (GH220, GH259).Renaming a Cells to a name already defined in a sub Space is now rejected with a
ValueError. Previously the sub Space’s own member was silently overwritten and lost (GH260).Deleting a derived Reference no longer corrupts the model. Previously
delon an inherited Reference raisedValueError: list.remove(x): x not in listafter the Reference had already been replaced and dependent cached values cleared.Deleting a Reference created by
new_space(refs={...})no longer raises anAssertionError.Deleting a Reference that overrides a Reference in a base Space now recalculates the Cells of deeper sub Spaces. Previously those Cells kept returning values computed from the removed overriding value.
Model.update_pandasandModel.update_moduleno longer corrupt therefmodeof the References they update.'absolute'became('absolute',)and the default'auto'became('auto',), and the corrupted value was inherited by derived References in sub Spaces.A failed
read_model()no longer leaves the IO objects and IO specs it created registered in modelx (GH256). Previously such a failure could make the model unreadable for the rest of the session, keep the half-built model alive in memory, and cause later saves of other models to overwrite the leaked IO files.A failed
read_model()now restores the models that were open before the read (GH257). Previously a model displaced by the read was left stranded under its_BAKname andcur_model()was left unset, so the next operation depending on the current model silently created a new empty model.Reading a model saved with an old version of pandas no longer aborts on pandas 3.0, which removed a compatibility function modelx relied on (GH255). The model now loads, with the values that pandas 3.0 can no longer rebuild replaced by
Noneand reported throughUserWarning.The retry warning issued when writing to a zip archive on a network drive fails now shows the path of the archive instead of a literal
'%s'(GH82).The
write_model()documentation no longer describes serializer 2/4/5 behavior for Spaces and Cells created bynew_space_from_excelandnew_cells_from_excel. Since serializer version 6 (modelx v0.22.0), such Spaces and Cells are written like any other member and their source Excel files are neither copied nor needed on read.
Changes#
Starting with this release, modelx is no longer tested against Python 3.7 and 3.8, both of which have reached their end of life. modelx is now tested against Python 3.9 through 3.14 on Linux, macOS and Windows. While modelx may still function with Python 3.7 and 3.8, it won’t be tested against these versions anymore.