diff --git a/.github/workflows/ci_test_all.yml b/.github/workflows/ci_test_all.yml index 1952f70..789b8d4 100644 --- a/.github/workflows/ci_test_all.yml +++ b/.github/workflows/ci_test_all.yml @@ -31,6 +31,10 @@ jobs: brew install hdf5 - 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... 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/docs/sphinx/source/api/Contributors.md b/docs/sphinx/source/api/Contributors.md index 58f73b1..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-05-19. +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 - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
sidstr SId optional
namestr string optional
metaidstr XML ID optional
sboTermstrSBOTerm optional
notesNotes XHTML 1.0 optional
annotationstrXML content optional
speciesstrSIdRef
stoichiometryfloatdouble optional
constantboolboolean
- -## Notes -XHTML field of SBase - -### Allowed parameters - - - - - - - - - - - - - - -
xmlnsstrstr fixed "http://www.w3.org/1999/xhtml"
contentstrstr 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/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 diff --git a/src/modelspec/__init__.py b/src/modelspec/__init__.py index 63880e4..87b6d85 100644 --- a/src/modelspec/__init__.py +++ b/src/modelspec/__init__.py @@ -1,4 +1,4 @@ -__version__ = "0.3.9" +__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 9b31e9c..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 @@ -837,9 +842,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 +856,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 +907,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 +921,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]) @@ -937,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): @@ -1082,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 b6c6808..e51d0f9 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 ) @@ -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")