diff --git a/docs/sphinx/source/api/Contributors.md b/docs/sphinx/source/api/Contributors.md
index a23950b..7123894 100644
--- a/docs/sphinx/source/api/Contributors.md
+++ b/docs/sphinx/source/api/Contributors.md
@@ -3,7 +3,7 @@
# Modelspec contributors
This page list names and Github profiles of contributors to Modelspec, listed in no particular order.
-This page is generated periodically, most recently on 2026-06-10.
+This page is generated periodically, most recently on 2026-06-24.
- Padraig Gleeson ([@pgleeson](https://github.com/pgleeson))
- Manifest Chakalov ([@mqnifestkelvin](https://github.com/mqnifestkelvin))
diff --git a/examples/sbml/SBML.md b/examples/sbml/SBML.md
index 664e42e..3a36b45 100644
--- a/examples/sbml/SBML.md
+++ b/examples/sbml/SBML.md
@@ -1670,95 +1670,6 @@ XHTML field of SBase
-
-
-## SpeciesReference
-### Allowed parameters
-
-
- | sid |
- str |
- SId optional |
-
-
-
-
- | name |
- str |
- string optional |
-
-
-
-
- | metaid |
- str |
- XML ID optional |
-
-
-
-
- | sboTerm |
- str |
- SBOTerm optional |
-
-
-
-
- | notes |
- Notes |
- XHTML 1.0 optional |
-
-
-
-
- | annotation |
- str |
- XML content optional |
-
-
-
-
- | species |
- str |
- SIdRef |
-
-
-
-
- | stoichiometry |
- float |
- double optional |
-
-
-
-
- | constant |
- bool |
- boolean |
-
-
-
-
-
-## Notes
-XHTML field of SBase
-
-### Allowed parameters
-
-
- | xmlns |
- str |
- str fixed "http://www.w3.org/1999/xhtml" |
-
-
-
-
- | content |
- str |
- str valid XHTML |
-
-
-
## ModifierSpeciesReference
diff --git a/examples/sbml/SBML.rst b/examples/sbml/SBML.rst
index 083cfc0..98c2acc 100644
--- a/examples/sbml/SBML.rst
+++ b/examples/sbml/SBML.rst
@@ -650,39 +650,6 @@ Allowed field Data Type Description
**content** str str valid XHTML
=============== =========== ========================================
-================
-SpeciesReference
-================
-**Allowed parameters**
-
-================= ======================================= ====================
-Allowed field Data Type Description
-================= ======================================= ====================
-**sid** str SId optional
-**name** str string optional
-**metaid** str XML ID optional
-**sboTerm** str SBOTerm optional
-**notes** ` <#notes>`__ XHTML 1.0 optional
-**annotation** str XML content optional
-**species** str SIdRef
-**stoichiometry** float double optional
-**constant** bool boolean
-================= ======================================= ====================
-
-=====
-Notes
-=====
-XHTML field of SBase
-
-**Allowed parameters**
-
-=============== =========== ========================================
-Allowed field Data Type Description
-=============== =========== ========================================
-**xmlns** str str fixed "http://www.w3.org/1999/xhtml"
-**content** str str valid XHTML
-=============== =========== ========================================
-
========================
ModifierSpeciesReference
========================
diff --git a/src/modelspec/__init__.py b/src/modelspec/__init__.py
index 16d4816..87b6d85 100644
--- a/src/modelspec/__init__.py
+++ b/src/modelspec/__init__.py
@@ -1,4 +1,4 @@
-__version__ = "0.3.10"
+__version__ = "0.4.0"
from .base_types import Base, define, has, field, fields, optional, instance_of, in_
diff --git a/src/modelspec/base_types.py b/src/modelspec/base_types.py
index 7241375..a7e119d 100644
--- a/src/modelspec/base_types.py
+++ b/src/modelspec/base_types.py
@@ -44,8 +44,10 @@ class EvaluableExpression(str):
str, so it can be used as a string.
"""
- def __init__(self, expr):
- self.expr = expr
+ @property
+ def expr(self):
+ """The expression string. Always reflects the underlying str value."""
+ return str(self)
# Union of types that are allowed as value expressions for parameters.
@@ -62,7 +64,7 @@ def print_(text: str, print_it: bool = False):
prefix = "modelspec >>> "
if not isinstance(text, str):
- text = ("%s" % text).decode("ascii")
+ text = "%s" % text
print("{}{}".format(prefix, text.replace("\n", "\n" + prefix)))
@@ -111,9 +113,9 @@ def to_json(self) -> str:
"""
return json.dumps(self.to_dict(), indent=4)
- def to_bson(self) -> str:
+ def to_bson(self) -> bytes:
"""
- Convert the Base object to a BSON string representation.
+ Convert the Base object to a BSON (bytes) representation.
"""
return bson.encode(self.to_dict())
@@ -238,7 +240,9 @@ def to_json_file(
return filename
- def to_bson_file(self, filename: str, include_metadata: bool = True) -> str:
+ def to_bson_file(
+ self, filename: Optional[str] = None, include_metadata: bool = True
+ ) -> str:
"""Convert modelspec format to bson format
Args:
@@ -321,7 +325,8 @@ def to_xml_file(
def from_file(cls, filename: str) -> "Base":
"""
Create a :class:`.Base` from its representation stored in a file. Auto-detect the correct deserialization code
- based on file extension. Currently supported formats are; JSON(.json) and YAML (.yaml or .yml)
+ based on file extension. Currently supported formats are: JSON (.json), YAML (.yaml or .yml),
+ BSON (.bson) and XML (.xml).
Args:
filename: The name of the file to load.
@@ -340,7 +345,7 @@ def from_file(cls, filename: str) -> "Base":
else:
raise ValueError(
f"Cannot auto-detect modelspec serialization format from filename ({filename}). The filename "
- f"must have one of the following extensions: .json, .yml, or .yaml."
+ f"must have one of the following extensions: .json, .yaml, .yml, .bson, or .xml."
)
@classmethod
@@ -945,10 +950,19 @@ def insert_links(text, format=MARKDOWN_FORMAT):
)
)
+ # De-duplicate while preserving order, so a type referenced by more than
+ # one field/child doesn't get its documentation section emitted twice.
+ seen = set()
+ unique_referenced = []
for r in referenced:
+ if r not in seen:
+ seen.add(r)
+ unique_referenced.append(r)
+
+ for r in unique_referenced:
if format in (MARKDOWN_FORMAT, RST_FORMAT):
doc_string += r._cls_generate_documentation(format=format)
- if format in (DICT_FORMAT):
+ if format == DICT_FORMAT:
doc_dict.update(r._cls_generate_documentation(format=format))
if format in (MARKDOWN_FORMAT, RST_FORMAT):
@@ -1090,7 +1104,14 @@ def _is_list_base(cl):
Check if a class is a list of Base objects. These will be serialized as dicts if the underlying class has an id
attribute.
"""
- return get_origin(cl) is list and issubclass(get_args(cl)[0], Base)
+ if get_origin(cl) is not list:
+ return False
+
+ args = get_args(cl)
+ # Guard against a bare ``list`` annotation (no args) and against element
+ # types that aren't classes (e.g. List[Union[A, B]]), which would make
+ # issubclass() raise TypeError.
+ return len(args) > 0 and isinstance(args[0], type) and issubclass(args[0], Base)
converter.register_unstructure_hook_factory(_is_list_base, _unstructure_list_base)
diff --git a/src/modelspec/utils.py b/src/modelspec/utils.py
index b4cab16..e51d0f9 100644
--- a/src/modelspec/utils.py
+++ b/src/modelspec/utils.py
@@ -336,7 +336,7 @@ def _parse_attributes(dict_format, to_build):
ff = type_to_use()
print_(f" Type for {key}: {type_to_use} ({ff})", verbose)
ff = _parse_element({v: value[v]}, ff)
- exec("to_build.%s.append(ff)" % key)
+ getattr(to_build, key).append(ff)
else:
if (
isinstance(value, str)
@@ -361,7 +361,7 @@ def _parse_attributes(dict_format, to_build):
else:
ff = type_to_use()
ff = _parse_attributes(value, ff)
- exec("to_build.%s = ff" % key)
+ setattr(to_build, key, ff)
else:
if isinstance(to_build, dict):
@@ -378,12 +378,12 @@ def _parse_attributes(dict_format, to_build):
for vl in value:
ff = type_to_use()
ff = _parse_element(vl, ff)
- exec("to_build.%s.append(ff)" % key)
+ getattr(to_build, key).append(ff)
else:
type_to_use = to_build.allowed_fields[key][1]
ff = type_to_use()
ff = _parse_attributes(value, ff)
- exec("to_build.%s = ff" % key)
+ setattr(to_build, key, ff)
return to_build
@@ -445,7 +445,7 @@ def _params_info(parameters, multiline=False):
def evaluate(
expr: Union[int, float, str, list, dict],
- parameters: dict = {},
+ parameters: dict = None,
rng: Random = None,
array_format: str = FORMAT_NUMPY,
verbose: bool = False,
@@ -465,6 +465,10 @@ def evaluate(
cast_to_int: return an int for float/string values if castable
"""
+ # Work on a private copy so we never mutate the caller's dict (or a shared
+ # default) when injecting rng/math/numpy below or when eval() adds __builtins__.
+ parameters = dict(parameters) if parameters is not None else {}
+
if array_format == FORMAT_TENSORFLOW:
import tensorflow as tf
@@ -591,3 +595,5 @@ def parse_list_like(list_str):
pass
if "[" in list_str:
return eval(list_str)
+
+ raise ValueError(f"Cannot parse {list_str!r} ({type(list_str)}) as a list")