From e05f8fc9128e57e0300a6270709b1ffa065c2907 Mon Sep 17 00:00:00 2001
From: Padraig Gleeson
Date: Wed, 29 Apr 2026 18:34:45 +0100
Subject: [PATCH 1/7] Update CI workflow to use bash shell for install step
---
.github/workflows/ci_test_all.yml | 1 +
1 file changed, 1 insertion(+)
diff --git a/.github/workflows/ci_test_all.yml b/.github/workflows/ci_test_all.yml
index 1952f70..77a9f09 100644
--- a/.github/workflows/ci_test_all.yml
+++ b/.github/workflows/ci_test_all.yml
@@ -31,6 +31,7 @@ jobs:
brew install hdf5
- name: Install & test package
+ shell: bash
run: |
python -m pip install --upgrade pip
# pip install 'numpy<2.0.0' # due to lingering issues with other modules & numpy...
From 87c59888f25be4c8838a6e88a64fd665042479a4 Mon Sep 17 00:00:00 2001
From: Padraig Gleeson
Date: Wed, 29 Apr 2026 18:50:39 +0100
Subject: [PATCH 2/7] Updated docstringe for test example
---
examples/test/test.md | 6 +++---
examples/test/test.py | 8 ++++----
examples/test/test.specification.yaml | 7 ++++---
examples/test/test_instance.json | 2 +-
examples/test/test_instance.xml | 2 +-
examples/test/test_instance.yaml | 2 +-
6 files changed, 14 insertions(+), 13 deletions(-)
diff --git a/examples/test/test.md b/examples/test/test.md
index ccfd2fe..b6af944 100644
--- a/examples/test/test.md
+++ b/examples/test/test.md
@@ -13,21 +13,21 @@ A model....
| float_like_req |
float |
- name says it all... |
+ Anything (float, str, int) which can be converted to a float with float(x) |
| float_like_optional |
float |
- name also says it all... |
+ Same as floatlikereq, but optional |
| int_like_optional |
int |
- name also says it all... |
+ Same as floatlikereq, but optional int |
diff --git a/examples/test/test.py b/examples/test/test.py
index afa628b..d082c5f 100644
--- a/examples/test/test.py
+++ b/examples/test/test.py
@@ -48,9 +48,9 @@ class TopClass(Base):
Args:
id: The unique id of the thing
- float_like_req: name says it all...
- float_like_optional: name also says it all...
- int_like_optional: name also says it all...
+ float_like_req: Anything (float, str, int) which can be converted to a float with float(x)
+ float_like_optional: Same as float_like_req, but optional
+ int_like_optional: Same as float_like_req, but optional int
"""
id: str = field(validator=instance_of(str))
@@ -74,7 +74,7 @@ class TopClass(Base):
) # a string which can be converted to a float...
# tc.float_like_req = 2.01
-tc.float_like_optional = 44
+tc.float_like_optional = "42"
# tc.float_like_optional2 = 66
tc.mid = MidClassNoId(int_val=4, str_val="three")
diff --git a/examples/test/test.specification.yaml b/examples/test/test.specification.yaml
index 4ffaa7a..57f2917 100644
--- a/examples/test/test.specification.yaml
+++ b/examples/test/test.specification.yaml
@@ -6,13 +6,14 @@ TopClass:
description: The unique id of the thing
float_like_req:
type: float
- description: name says it all...
+ description: Anything (float, str, int) which can be converted to a float
+ with float(x)
float_like_optional:
type: float
- description: name also says it all...
+ description: Same as float_like_req, but optional
int_like_optional:
type: int
- description: name also says it all...
+ description: Same as float_like_req, but optional int
mid:
type: MidClassNoId
description: ''
diff --git a/examples/test/test_instance.json b/examples/test/test_instance.json
index 7b8b087..47de2a3 100644
--- a/examples/test/test_instance.json
+++ b/examples/test/test_instance.json
@@ -1,7 +1,7 @@
{
"MyTest": {
"float_like_req": 4.0,
- "float_like_optional": 44.0,
+ "float_like_optional": 42.0,
"mid": {
"int_val": 4,
"str_val": "three"
diff --git a/examples/test/test_instance.xml b/examples/test/test_instance.xml
index 4895ba5..496b084 100644
--- a/examples/test/test_instance.xml
+++ b/examples/test/test_instance.xml
@@ -1,4 +1,4 @@
-
+
diff --git a/examples/test/test_instance.yaml b/examples/test/test_instance.yaml
index 1a3bcd4..f64bbfe 100644
--- a/examples/test/test_instance.yaml
+++ b/examples/test/test_instance.yaml
@@ -1,6 +1,6 @@
MyTest:
float_like_req: 4.0
- float_like_optional: 44.0
+ float_like_optional: 42.0
mid:
int_val: 4
str_val: three
From cc0bbafe63d35672bbd85e6b3a8e5a1ba35ed467 Mon Sep 17 00:00:00 2001
From: Padraig Gleeson
Date: Wed, 29 Apr 2026 18:50:51 +0100
Subject: [PATCH 3/7] update gha env for windows
---
.github/workflows/ci_test_all.yml | 3 +++
1 file changed, 3 insertions(+)
diff --git a/.github/workflows/ci_test_all.yml b/.github/workflows/ci_test_all.yml
index 77a9f09..789b8d4 100644
--- a/.github/workflows/ci_test_all.yml
+++ b/.github/workflows/ci_test_all.yml
@@ -32,6 +32,9 @@ jobs:
- name: Install & test package
shell: bash
+ env:
+ PYTHONUTF8: "1"
+ PYTHONIOENCODING: "utf-8"
run: |
python -m pip install --upgrade pip
# pip install 'numpy<2.0.0' # due to lingering issues with other modules & numpy...
From 1d5ea424f4ef502e84fcd300b57f9f9e8360957f Mon Sep 17 00:00:00 2001
From: Padraig Gleeson
Date: Wed, 29 Apr 2026 18:59:23 +0100
Subject: [PATCH 4/7] Updated precommit/black
---
.pre-commit-config.yaml | 4 ++--
src/modelspec/base_types.py | 32 ++++++++++++++++++++------------
src/modelspec/utils.py | 4 ++--
3 files changed, 24 insertions(+), 16 deletions(-)
diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml
index afa9865..de0e3d9 100644
--- a/.pre-commit-config.yaml
+++ b/.pre-commit-config.yaml
@@ -6,7 +6,7 @@ repos:
# - id: black
- repo: https://github.com/pre-commit/pre-commit-hooks
- rev: v3.4.0
+ rev: v6.0.0
hooks:
- id: check-added-large-files
args: ['--maxkb=800']
@@ -46,6 +46,6 @@ repos:
# files: src
- repo: https://github.com/psf/black
- rev: 22.3.0
+ rev: 24.10.0
hooks:
- id: black
diff --git a/src/modelspec/base_types.py b/src/modelspec/base_types.py
index 9b31e9c..7241375 100644
--- a/src/modelspec/base_types.py
+++ b/src/modelspec/base_types.py
@@ -837,9 +837,11 @@ def insert_links(text, format=MARKDOWN_FORMAT):
doc_string += (
"\n \n | {} | \n {} | ".format(
f,
- f'{type_str}'
- if referencable
- else type_str,
+ (
+ f'{type_str}'
+ if referencable
+ else type_str
+ ),
)
)
doc_string += "\n %s | \n
\n\n" % (
@@ -849,9 +851,11 @@ def insert_links(text, format=MARKDOWN_FORMAT):
elif format == RST_FORMAT:
n = "**%s**" % f
t = "{}".format(
- rst_url_format % (type_, "#" + type_str.lower())
- if referencable
- else type_str,
+ (
+ rst_url_format % (type_, "#" + type_str.lower())
+ if referencable
+ else type_str
+ ),
)
d = "%s" % (insert_links(description, format=RST_FORMAT))
table_info.append([n, t, d])
@@ -898,9 +902,11 @@ def insert_links(text, format=MARKDOWN_FORMAT):
doc_string += (
"\n \n | {} | \n {} | ".format(
c,
- f'{type_str}'
- if referencable
- else type_str,
+ (
+ f'{type_str}'
+ if referencable
+ else type_str
+ ),
)
)
doc_string += "\n %s | \n
\n\n" % (
@@ -910,9 +916,11 @@ def insert_links(text, format=MARKDOWN_FORMAT):
elif format == RST_FORMAT:
n = "**%s**" % c
t = "{}".format(
- rst_url_format % (type_str, "#" + type_str.lower())
- if referencable
- else type_str,
+ (
+ rst_url_format % (type_str, "#" + type_str.lower())
+ if referencable
+ else type_str
+ ),
)
d = "%s" % (insert_links(description, format=RST_FORMAT))
table_info.append([n, t, d])
diff --git a/src/modelspec/utils.py b/src/modelspec/utils.py
index b6c6808..b4cab16 100644
--- a/src/modelspec/utils.py
+++ b/src/modelspec/utils.py
@@ -296,8 +296,8 @@ def build_xml_element(data, parent=None):
def ascii_encode_dict(data):
- ascii_encode = (
- lambda x: x.encode("ascii")
+ ascii_encode = lambda x: (
+ x.encode("ascii")
if (sys.version_info[0] == 2 and isinstance(x, unicode))
else x
)
From f016146986b28b54e2955cf6e2791f256f333ee4 Mon Sep 17 00:00:00 2001
From: Padraig Gleeson
Date: Wed, 20 May 2026 16:35:46 +0100
Subject: [PATCH 5/7] To v0.3.10
---
src/modelspec/__init__.py | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/src/modelspec/__init__.py b/src/modelspec/__init__.py
index 63880e4..16d4816 100644
--- a/src/modelspec/__init__.py
+++ b/src/modelspec/__init__.py
@@ -1,4 +1,4 @@
-__version__ = "0.3.9"
+__version__ = "0.3.10"
from .base_types import Base, define, has, field, fields, optional, instance_of, in_
From 3be30e1f31e6edcf25640e5c263ed39e5b284bf7 Mon Sep 17 00:00:00 2001
From: Padraig Gleeson
Date: Wed, 10 Jun 2026 14:49:43 +0100
Subject: [PATCH 6/7] Regenerated contribs
---
docs/sphinx/source/api/Contributors.md | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/docs/sphinx/source/api/Contributors.md b/docs/sphinx/source/api/Contributors.md
index 58f73b1..a23950b 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-05-19.
+This page is generated periodically, most recently on 2026-06-10.
- Padraig Gleeson ([@pgleeson](https://github.com/pgleeson))
- Manifest Chakalov ([@mqnifestkelvin](https://github.com/mqnifestkelvin))
From 197354bfbf5cd9bb2e489de5922fca442892d399 Mon Sep 17 00:00:00 2001
From: Padraig Gleeson
Date: Wed, 24 Jun 2026 14:47:26 +0100
Subject: [PATCH 7/7] More internal fixes/tidying
---
docs/sphinx/source/api/Contributors.md | 2 +-
examples/sbml/SBML.md | 89 --------------------------
examples/sbml/SBML.rst | 33 ----------
src/modelspec/__init__.py | 2 +-
src/modelspec/base_types.py | 41 +++++++++---
src/modelspec/utils.py | 16 +++--
6 files changed, 44 insertions(+), 139 deletions(-)
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")