From 2ab8ea02378f06de33888f9d41d5b25ce62718b7 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 7 Dec 2025 12:48:08 +0000 Subject: [PATCH 1/8] Initial plan From e06a0558bdec06c41fd1c7ffd8e33554055b6b20 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 7 Dec 2025 12:55:44 +0000 Subject: [PATCH 2/8] Add core Macro functionality: Macro class, defmacro decorator, and model integration Co-authored-by: fumitoh <8450892+fumitoh@users.noreply.github.com> --- modelx/core/api.py | 109 +++++++++++++++++++++++ modelx/core/macro.py | 206 +++++++++++++++++++++++++++++++++++++++++++ modelx/core/model.py | 107 +++++++++++++++++++++- modelx/core/views.py | 8 ++ 4 files changed, 429 insertions(+), 1 deletion(-) create mode 100644 modelx/core/macro.py diff --git a/modelx/core/api.py b/modelx/core/api.py index 671da179..9b81e20e 100644 --- a/modelx/core/api.py +++ b/modelx/core/api.py @@ -35,6 +35,7 @@ from modelx.core import mxsys as _system from modelx.core.cells import CellsMaker as _CellsMaker +from modelx.core.macro import MacroMaker as _MacroMaker from modelx.core.space import BaseSpace as _Space from modelx.core.model import Model as _Model from modelx.core.base import get_interface_dict as _get_interfaces @@ -274,6 +275,114 @@ def foo(x): return defcells(space, name, is_cached=False, *funcs) +def defmacro(model=None, name=None, *funcs): + """Decorator to create or update a macro from a Python function. + + This convenience function serves as a decorator to create a new macro or + update an existing macro directly from a Python function definition. + Macros are Python functions that can be saved within a Model and executed + to manipulate or query the model. + + All macros in a model share the same dedicated global namespace. + In the namespace, the model is defined as a global variable, ``mx_model`` + as well as by its model name. + + Examples: + + **1. As a decorator without arguments** + + The code below creates a macro in the current model. + If a macro with the same name already exists, updates its formula. + + If the current model does not exist, a new model is created:: + + >>> import modelx as mx + + >>> m = mx.new_model('MyModel') + + >>> @mx.defmacro + ... def get_model_name(): + ... return mx_model.name + + >>> get_model_name + + + >>> m.get_model_name() + 'MyModel' + + **2. As a decorator with arguments** + + The code below creates a macro in a specified model with the specified name:: + + >>> m = mx.new_model('MyModel') + + >>> @mx.defmacro(model=m, name='print_name') + ... def print_model_name(message): + ... print(f"{message} {get_model_name()}") + + >>> print_model_name + + + >>> m.print_name("This model is") + This model is MyModel + + **3. As a function** + + Creates multiple macros from multiple function definitions:: + + def foo(): + return mx_model.name + + def bar(): + return foo() + + foo, bar = defmacro(foo, bar) + + Args: + model (optional): For usage 2, specifies the model to create the macro in. + Defaults to the current model. + name (optional): For usage 2, specifies the name of the created macro. + Defaults to the function name. + *funcs: For usage 3, function objects. (``model`` and ``name`` can also + accept function objects for this usage.) + + Returns: + For usage 1 and 2, the newly created single macro is returned. + For usage 3, a list of newly created macros is returned. + + .. versionadded:: 0.30.0 + """ + if isinstance(model, _FunctionType) and name is None: + # called as a function decorator + func = model + cur_model_obj = cur_model() + if cur_model_obj is None: + cur_model_obj = new_model() + return _MacroMaker( + model=cur_model_obj._impl, name=func.__name__ + ).create_or_change_macro(func) + + elif (isinstance(model, _Model) or model is None) and ( + isinstance(name, str) or name is None + ): + # return decorator itself + if model is None: + cur_model_obj = cur_model() + if cur_model_obj is None: + cur_model_obj = new_model() + model = cur_model_obj + + return _MacroMaker(model=model._impl, name=name) + + elif all( + isinstance(func, _FunctionType) for func in (model, name) + funcs + ): + return [defmacro(func) for func in (model, name) + funcs] + + else: + raise TypeError("invalid defmacro arguments") + + def get_models(): """Returns a dict that maps model names to models. diff --git a/modelx/core/macro.py b/modelx/core/macro.py new file mode 100644 index 00000000..4407f81b --- /dev/null +++ b/modelx/core/macro.py @@ -0,0 +1,206 @@ +# Copyright (c) 2017-2025 Fumito Hamamura + +# This library is free software: you can redistribute it and/or +# modify it under the terms of the GNU Lesser General Public +# License as published by the Free Software Foundation version 3. +# +# This library is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +# Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public +# License along with this library. If not, see . + +from collections.abc import Callable + +from modelx.core.base import ( + Impl, Interface, get_mixin_slots +) +from modelx.core.formula import Formula +from modelx.core.util import is_valid_name + + +class MacroMaker: + """Factory for creating Macro objects""" + + def __init__(self, *, model, name): + self.model = model # ModelImpl + self.name = name + + def __call__(self, func): + return self.create_or_change_macro(func) + + def create_or_change_macro(self, func): + self.name = func.__name__ if self.name is None else self.name + + if is_valid_name(self.name) and self.name in self.model.macros: + # Update existing macro + macro = self.model.macros[self.name] + macro.set_formula(func) + return macro.interface + else: + # Create new macro + return self.model.new_macro(name=self.name, formula=func).interface + + +class Macro(Interface, Callable): + """A callable Python function that can be saved within a Model. + + Macros are Python functions stored in a model that can be used to + manipulate and interact with the model. All macros in a model share + a dedicated global namespace that includes the model itself as + both ``mx_model`` and by the model's name. + + Creation: + Macros can be created using the :func:`~modelx.defmacro` decorator:: + + >>> import modelx as mx + >>> m = mx.new_model('MyModel') + + >>> @mx.defmacro + ... def get_model_name(): + ... return mx_model.name + + >>> @mx.defmacro(model=m, name='print_name') + ... def print_model_name(message): + ... print(f"{message} {get_model_name()}") + + Execution: + Macros are executed by calling them as model attributes:: + + >>> m.get_model_name() + 'MyModel' + + >>> m.print_name("This model is") + This model is MyModel + + Listing Macros: + Access all macros through the model's :attr:`~modelx.core.model.Model.macros` + property:: + + >>> m.macros + {'get_model_name': , + 'print_name': } + + Export: + When a model is exported, macros are saved in ``_mx_macros.py`` as + regular Python functions, allowing them to work with both modelx + models and exported models. + + See Also: + :func:`~modelx.defmacro`: Decorator to create macros + :attr:`~modelx.core.model.Model.macros`: Access model's macros + :meth:`~modelx.core.model.Model.export`: Export model as Python package + + .. versionadded:: 0.30.0 + """ + + __slots__ = () + + def __call__(self, *args, **kwargs): + """Execute the macro with given arguments""" + return self._impl.execute(*args, **kwargs) + + def __repr__(self): + return f"" + + @property + def formula(self): + """The formula object of the macro""" + return self._impl.formula + + @property + def parent(self): + """The parent model of the macro""" + if self._impl.parent is not None: + return self._impl.parent.interface + else: + return None + + +class MacroImpl(Impl): + """Implementation of Macro interface""" + + interface_cls = Macro + + __slots__ = ( + "formula", + "_namespace" + ) + get_mixin_slots(Impl) + + def __init__(self, *, system, parent, name, formula): + """Initialize MacroImpl + + Args: + system: The system object + parent: The parent ModelImpl object + name: Name of the macro + formula: Formula object or callable + """ + Impl.__init__( + self, + system=system, + parent=parent, + name=name, + spmgr=parent.spmgr + ) + + if not isinstance(formula, Formula): + formula = Formula(formula) + + self.formula = formula + self._namespace = None + + def execute(self, *args, **kwargs): + """Execute the macro function + + Args: + *args: Positional arguments for the macro function + **kwargs: Keyword arguments for the macro function + + Returns: + The return value of the macro function + """ + # Get the namespace with mx_model and model name + namespace = self.parent.get_macro_namespace() + + # Execute the function with the namespace as globals + func = self.formula.func + + # Create a new function with the correct globals + import types + new_func = types.FunctionType( + func.__code__, + namespace, + func.__name__, + func.__defaults__, + func.__closure__ + ) + + return new_func(*args, **kwargs) + + def set_formula(self, func): + """Update the macro's formula + + Args: + func: New function to use as the formula + """ + if not isinstance(func, Formula): + func = Formula(func) + self.formula = func + + def repr_parent(self): + """Return parent representation""" + if self.parent.repr_parent(): + return self.parent.repr_parent() + "." + self.parent.repr_self() + else: + return self.parent.repr_self() + + def repr_self(self, add_params=True): + """Return self representation""" + return self.name + + def on_delete(self): + """Cleanup when macro is deleted""" + pass diff --git a/modelx/core/model.py b/modelx/core/model.py index 6bfd18bb..41ecdee0 100644 --- a/modelx/core/model.py +++ b/modelx/core/model.py @@ -41,7 +41,8 @@ from modelx.core.util import is_valid_name from modelx.core.execution.trace import TraceManager from modelx.core.chainmap import CustomChainMap -from modelx.core.views import RefView +from modelx.core.views import RefView, MacroView +from modelx.core.macro import MacroImpl class IOSpecOperation: @@ -504,6 +505,36 @@ def tracegraph(self): def refs(self): """Return a mapping of global references.""" return RefView(self._impl.global_refs) + + @property + def macros(self): + """Return a mapping of macros. + + Returns a dictionary-like view of all macros defined in the model. + Macros are Python functions that can be saved within the model and + executed to manipulate or query the model. + + Example: + >>> import modelx as mx + >>> m = mx.new_model('MyModel') + + >>> @mx.defmacro + ... def get_name(): + ... return mx_model.name + + >>> m.macros + {'get_name': } + + >>> m.get_name() + 'MyModel' + + See Also: + :func:`~modelx.defmacro`: Decorator to create macros + :class:`~modelx.core.macro.Macro`: Macro class documentation + + .. versionadded:: 0.30.0 + """ + return MacroView(self._impl._macros) def _get_from_name(self, name): """Get object by named id""" @@ -1057,6 +1088,8 @@ class ModelImpl(*_model_impl_base): "_namespace", "_global_refs", "_property_refs", + "_macros", + "_macro_namespace", "currentspace", "path", "refmgr" @@ -1083,6 +1116,8 @@ def __init__(self, *, system, name): self._property_refs["path"] = ReferenceImpl( self, "path", self.path, container=self._property_refs, set_item=False) + self._macros = {} + self._macro_namespace = None self.named_spaces = {} self._namespace = CustomChainMap(self.named_spaces, self._global_refs) self.namespace = ModelNamespace(self) @@ -1169,6 +1204,8 @@ def get_attr(self, name): return self.spaces[name].interface elif name in self.global_refs: return self.global_refs[name].interface + elif name in self._macros: + return self._macros[name].interface else: raise AttributeError( "Model '{0}' does not have '{1}'".format(self.name, name) @@ -1177,6 +1214,8 @@ def get_attr(self, name): def set_attr(self, name, value, refmode=None): if name in self.spaces: raise KeyError("Space named '%s' already exist" % self.name) + elif name in self._macros: + raise KeyError("Macro named '%s' already exists" % name) elif name in self.global_refs: self.refmgr.change_ref(self, name, value) else: @@ -1186,10 +1225,76 @@ def del_attr(self, name): if name in self.named_spaces: self.updater.del_defined_space(self.named_spaces[name]) + elif name in self._macros: + self.del_macro(name) elif name in self.global_refs: self.refmgr.del_ref(self, name) else: raise KeyError("Name '%s' not defined" % name) + + # Macro methods + + def new_macro(self, name, formula): + """Create a new macro + + Args: + name: Name of the macro + formula: Formula object or callable + + Returns: + MacroImpl instance + """ + if name in self._macros: + raise ValueError(f"Macro '{name}' already exists") + if name in self.spaces or name in self.global_refs: + raise ValueError(f"Name '{name}' already used") + + macro = MacroImpl( + system=self.system, + parent=self, + name=name, + formula=formula + ) + self._macros[name] = macro + return macro + + def del_macro(self, name): + """Delete a macro + + Args: + name: Name of the macro to delete + """ + if name not in self._macros: + raise KeyError(f"Macro '{name}' not found") + + macro = self._macros[name] + macro.on_delete() + del self._macros[name] + + def get_macro_namespace(self): + """Get the namespace for macro execution + + Returns a namespace dict with mx_model and the model's name + pointing to the model interface. + """ + if self._macro_namespace is None: + self._macro_namespace = {} + + # Always update to ensure it has the current state + self._macro_namespace['mx_model'] = self.interface + self._macro_namespace[self.name] = self.interface + self._macro_namespace['__builtins__'] = builtins + + # Add all macros to the namespace so they can call each other + for macro_name, macro_impl in self._macros.items(): + self._macro_namespace[macro_name] = macro_impl.interface + + return self._macro_namespace + + @property + def macros(self): + """Return the macros dictionary""" + return self._macros def to_node(self): return ObjectNode(get_node(self, None, None)) diff --git a/modelx/core/views.py b/modelx/core/views.py index 17794cc5..7a9b2127 100644 --- a/modelx/core/views.py +++ b/modelx/core/views.py @@ -226,6 +226,14 @@ def __delitem__(self, name): space.model.updater.del_defined_space(space) +class MacroView(BaseView): + """A mapping of macro names to macro objects.""" + + def __delitem__(self, name): + macro = self._impls[name] + macro.parent.del_macro(name) + + class RefView(BaseView): @property From 771dc6bc532b6623e813aba03ab0587778e27c55 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 7 Dec 2025 13:01:34 +0000 Subject: [PATCH 3/8] Add macro export support and comprehensive tests Co-authored-by: fumitoh <8450892+fumitoh@users.noreply.github.com> --- modelx/core/model.py | 4 +- modelx/export/_mx_sys.py | 5 + modelx/export/exporter.py | 49 +++++ modelx/tests/core/model/test_macro.py | 252 ++++++++++++++++++++++ modelx/tests/export/test_export_macros.py | 169 +++++++++++++++ 5 files changed, 478 insertions(+), 1 deletion(-) create mode 100644 modelx/tests/core/model/test_macro.py create mode 100644 modelx/tests/export/test_export_macros.py diff --git a/modelx/core/model.py b/modelx/core/model.py index 41ecdee0..4bd656b4 100644 --- a/modelx/core/model.py +++ b/modelx/core/model.py @@ -494,7 +494,9 @@ def __delattr__(self, name): self._impl.del_attr(name) def __dir__(self): - return list(self._impl._namespace) + result = list(self._impl._namespace) + result.extend(self._impl._macros.keys()) + return result @property def tracegraph(self): diff --git a/modelx/export/_mx_sys.py b/modelx/export/_mx_sys.py index feb84768..2e033b2c 100644 --- a/modelx/export/_mx_sys.py +++ b/modelx/export/_mx_sys.py @@ -21,6 +21,11 @@ class BaseParent(BaseMxObject): _parent: 'BaseParent' _model: 'BaseModel' _name: str + + @property + def name(self): + """Return the name of this object""" + return self._name def _mx_walk(self, skip_self: bool = False): """Generator yielding spaces in breadth-first order""" diff --git a/modelx/export/exporter.py b/modelx/export/exporter.py index 349c0818..acb01c90 100644 --- a/modelx/export/exporter.py +++ b/modelx/export/exporter.py @@ -42,6 +42,7 @@ MODEL_MODULE = '_mx_model' SPACE_MODULE = '_mx_classes' DATA_MODULE = '_mx_io' # _mx_io is hard-coded in _mx_sys +MACRO_MODULE = '_mx_macros' SPACE_PKG_PREFIX = '_m_' SPACE_CLS_PREFIX = '_c_' @@ -71,11 +72,21 @@ def export(self): # Write _mx_sys.py copy_file(this_dir / '_mx_sys.py', self.path / '_mx_sys.py') + + # Write _mx_macros.py if macros exist + if self.model.macros: + write_str_utf8( + MacroTranslator(self.model).code, + self.path / (MACRO_MODULE + '.py')) for parent in self.gen_parents(): if parent is self.model: init_line = f'from .{MODEL_MODULE} import ({MODEL_VAR}, {self.model.name})' + if self.model.macros: + # Add macros to __init__.py imports + macro_names = ', '.join(self.model.macros.keys()) + init_line += f'\nfrom .{MACRO_MODULE} import ({macro_names})' else: init_line = f"from . import {SPACE_MODULE}" @@ -615,3 +626,41 @@ def space_param_list(self, space): return ''.join(str_elm) else: return '' + + +class MacroTranslator: + """Translator for macros to _mx_macros.py""" + + module_template = textwrap.dedent("""\ + from .{MODEL_MODULE} import ({MODEL_VAR}, {model_name}) + + {macro_defs} + """) + + def __init__(self, model: Model): + self.model = model + + @cached_property + def code(self): + return self.module_template.format( + MODEL_MODULE=MODEL_MODULE, + MODEL_VAR=MODEL_VAR, + model_name=self.model.name, + macro_defs=self.macro_defs + ) + + @cached_property + def macro_defs(self): + """Generate function definitions for all macros""" + result = [] + for name, macro in self.model.macros.items(): + # Get the source code of the macro's formula + formula = macro.formula + if formula and formula.source: + result.append(formula.source) + else: + # If no source, create a stub + result.append(f"def {name}():\n pass") + + return "\n\n".join(result) + diff --git a/modelx/tests/core/model/test_macro.py b/modelx/tests/core/model/test_macro.py new file mode 100644 index 00000000..3aeb3bcb --- /dev/null +++ b/modelx/tests/core/model/test_macro.py @@ -0,0 +1,252 @@ +import pytest +import modelx as mx + + +@pytest.fixture +def simple_model(): + """Create a simple model for testing""" + model = mx.new_model(name="TestModel") + yield model + model._impl._check_sanity() + model.close() + + +def test_defmacro_basic(simple_model): + """Test basic macro creation without arguments""" + m = simple_model + + @mx.defmacro + def get_name(): + return mx_model.name + + assert 'get_name' in m.macros + assert get_name is m.macros['get_name'] + assert m.get_name() == 'TestModel' + + +def test_defmacro_with_args(simple_model): + """Test macro creation with model and name arguments""" + m = simple_model + + @mx.defmacro(model=m, name='custom_name') + def original_name(): + return mx_model.name + + assert 'custom_name' in m.macros + assert original_name is m.macros['custom_name'] + assert m.custom_name() == 'TestModel' + + +def test_defmacro_with_params(): + """Test macro with parameters""" + m = mx.new_model('ParamModel') + + @mx.defmacro + def add_numbers(a, b): + return a + b + + assert m.add_numbers(2, 3) == 5 + assert m.add_numbers(10, 20) == 30 + + m.close() + + +def test_macros_share_namespace(): + """Test that macros in a model share the same namespace""" + m = mx.new_model('SharedNS') + + @mx.defmacro + def get_value(): + return 42 + + @mx.defmacro + def use_other_macro(): + return get_value() * 2 + + assert m.get_value() == 42 + assert m.use_other_macro() == 84 + + m.close() + + +def test_macro_access_model_as_mx_model(): + """Test that macros can access model as mx_model""" + m = mx.new_model('AccessTest') + + @mx.defmacro + def get_model_via_mx_model(): + return mx_model.name + + assert m.get_model_via_mx_model() == 'AccessTest' + + m.close() + + +def test_macro_access_model_by_name(): + """Test that macros can access model by its name""" + m = mx.new_model('NamedAccess') + + @mx.defmacro + def get_model_via_name(): + return NamedAccess.name + + assert m.get_model_via_name() == 'NamedAccess' + + m.close() + + +def test_macros_property(simple_model): + """Test the macros property returns correct mapping""" + m = simple_model + + @mx.defmacro + def macro1(): + return 1 + + @mx.defmacro + def macro2(): + return 2 + + macros = m.macros + assert len(macros) == 2 + assert 'macro1' in macros + assert 'macro2' in macros + assert macros['macro1'] is macro1 + assert macros['macro2'] is macro2 + + +def test_macro_update_formula(simple_model): + """Test updating an existing macro's formula""" + m = simple_model + + @mx.defmacro + def my_macro(): + return "original" + + assert m.my_macro() == "original" + + @mx.defmacro + def my_macro(): + return "updated" + + assert m.my_macro() == "updated" + assert len(m.macros) == 1 + + +def test_macro_delete(simple_model): + """Test deleting a macro""" + m = simple_model + + @mx.defmacro + def to_delete(): + return "value" + + assert 'to_delete' in m.macros + + del m.to_delete + + assert 'to_delete' not in m.macros + with pytest.raises(AttributeError): + m.to_delete() + + +def test_macro_repr(): + """Test macro representation""" + m = mx.new_model('ReprTest') + + @mx.defmacro + def test_macro(): + return None + + repr_str = repr(test_macro) + assert 'Macro' in repr_str + assert 'ReprTest' in repr_str + assert 'test_macro' in repr_str + + m.close() + + +def test_defmacro_multiple_functions(): + """Test creating multiple macros at once""" + m = mx.new_model('MultiMacro') + + def func1(): + return 1 + + def func2(): + return 2 + + def func3(): + return 3 + + f1, f2, f3 = mx.defmacro(func1, func2, func3) + + assert m.func1() == 1 + assert m.func2() == 2 + assert m.func3() == 3 + + m.close() + + +def test_macro_no_model_creates_model(): + """Test that defmacro creates a model if none exists""" + # Close all models first + for model_name in list(mx.get_models().keys()): + mx.get_models()[model_name].close() + + @mx.defmacro + def test_func(): + return "created" + + # A model should have been created + models = mx.get_models() + assert len(models) > 0 + + # Clean up + for model in models.values(): + model.close() + + +def test_macro_with_kwargs(): + """Test macro with keyword arguments""" + m = mx.new_model('KwargsTest') + + @mx.defmacro + def greet(name, greeting="Hello"): + return f"{greeting}, {name}!" + + assert m.greet("World") == "Hello, World!" + assert m.greet("World", greeting="Hi") == "Hi, World!" + + m.close() + + +def test_macro_dir_includes_macros(simple_model): + """Test that dir() includes macro names""" + m = simple_model + + @mx.defmacro + def visible_macro(): + return None + + dir_result = dir(m) + assert 'visible_macro' in dir_result + + +def test_macro_error_duplicate_name(): + """Test that creating a macro with a duplicate name updates it""" + m = mx.new_model('DupTest') + + @mx.defmacro + def dup_name(): + return 1 + + # Should update, not error + @mx.defmacro + def dup_name(): + return 2 + + assert m.dup_name() == 2 + assert len(m.macros) == 1 + + m.close() diff --git a/modelx/tests/export/test_export_macros.py b/modelx/tests/export/test_export_macros.py new file mode 100644 index 00000000..a5e4faec --- /dev/null +++ b/modelx/tests/export/test_export_macros.py @@ -0,0 +1,169 @@ +import sys +import tempfile +import pathlib +import pytest +import modelx as mx + + +@pytest.fixture +def macro_model(): + """Create a model with macros for testing""" + m = mx.new_model('MacroTestModel') + + @mx.defmacro + def get_model_name(): + return mx_model.name + + @mx.defmacro + def add_numbers(a, b): + return a + b + + @mx.defmacro + def call_other_macro(): + return get_model_name() + "_suffix" + + yield m + m.close() + + +def test_export_macros_creates_file(macro_model, tmp_path): + """Test that exporting model with macros creates _mx_macros.py""" + export_path = tmp_path / 'exported_model' + macro_model.export(export_path) + + # Check that _mx_macros.py exists + macro_file = export_path / '_mx_macros.py' + assert macro_file.exists() + + +def test_export_macros_file_content(macro_model, tmp_path): + """Test that _mx_macros.py contains the macro definitions""" + export_path = tmp_path / 'exported_model' + macro_model.export(export_path) + + macro_file = export_path / '_mx_macros.py' + content = macro_file.read_text() + + # Check imports + assert 'from ._mx_model import' in content + assert 'mx_model' in content + assert 'MacroTestModel' in content + + # Check macro definitions + assert 'def get_model_name()' in content + assert 'def add_numbers(a, b)' in content + assert 'def call_other_macro()' in content + + +def test_export_macros_in_init(macro_model, tmp_path): + """Test that __init__.py imports macros""" + export_path = tmp_path / 'exported_model' + macro_model.export(export_path) + + init_file = export_path / '__init__.py' + content = init_file.read_text() + + # Check that macros are imported + assert 'from ._mx_macros import' in content + assert 'get_model_name' in content + assert 'add_numbers' in content + assert 'call_other_macro' in content + + +def test_export_macros_executable(macro_model, tmp_path): + """Test that exported macros can be executed""" + export_path = tmp_path / 'exported_model' + macro_model.export(export_path) + + try: + sys.path.insert(0, str(tmp_path)) + + # Import the exported module + from exported_model import get_model_name, add_numbers, call_other_macro + + # Test macros + assert get_model_name() == 'MacroTestModel' + assert add_numbers(3, 4) == 7 + assert call_other_macro() == 'MacroTestModel_suffix' + + finally: + sys.path.pop(0) + # Clean up imported modules + for mod in list(sys.modules.keys()): + if 'exported_model' in mod: + del sys.modules[mod] + + +def test_export_model_without_macros(tmp_path): + """Test that exporting model without macros doesn't create _mx_macros.py""" + m = mx.new_model('NoMacros') + export_path = tmp_path / 'exported_no_macros' + m.export(export_path) + + # Check that _mx_macros.py does NOT exist + macro_file = export_path / '_mx_macros.py' + assert not macro_file.exists() + + # Check that __init__.py doesn't import macros + init_file = export_path / '__init__.py' + content = init_file.read_text() + assert '_mx_macros' not in content + + m.close() + + +def test_export_macros_with_kwargs(tmp_path): + """Test that macros with default parameters are exported correctly""" + m = mx.new_model('KwargsModel') + + @mx.defmacro + def greet(name, greeting="Hello"): + return f"{greeting}, {name}!" + + export_path = tmp_path / 'exported_kwargs' + m.export(export_path) + + try: + sys.path.insert(0, str(tmp_path)) + from exported_kwargs import greet + + assert greet("World") == "Hello, World!" + assert greet("World", greeting="Hi") == "Hi, World!" + + finally: + sys.path.pop(0) + for mod in list(sys.modules.keys()): + if 'exported_kwargs' in mod: + del sys.modules[mod] + m.close() + + +def test_export_macro_access_model(tmp_path): + """Test that exported macros can access the model via mx_model""" + m = mx.new_model('AccessModel') + s = m.new_space('TestSpace') + + @mx.defcells + def foo(x): + return x * 2 + + @mx.defmacro + def use_model(): + return mx_model.TestSpace.foo(5) + + export_path = tmp_path / 'exported_access' + m.export(export_path) + + try: + sys.path.insert(0, str(tmp_path)) + from exported_access import use_model, mx_model as exported_model + + assert use_model() == 10 + assert exported_model.TestSpace.foo(5) == 10 + + finally: + sys.path.pop(0) + for mod in list(sys.modules.keys()): + if 'exported_access' in mod: + del sys.modules[mod] + m.close() From 981d0fb58a0b194b2b9e0aa643f1c03b43fcc3d0 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 7 Dec 2025 13:06:08 +0000 Subject: [PATCH 4/8] Address code review comments: move import to top, improve stub generation Co-authored-by: fumitoh <8450892+fumitoh@users.noreply.github.com> --- modelx/core/macro.py | 2 +- modelx/export/exporter.py | 10 ++++++++-- 2 files changed, 9 insertions(+), 3 deletions(-) diff --git a/modelx/core/macro.py b/modelx/core/macro.py index 4407f81b..e2b48532 100644 --- a/modelx/core/macro.py +++ b/modelx/core/macro.py @@ -12,6 +12,7 @@ # You should have received a copy of the GNU Lesser General Public # License along with this library. If not, see . +import types from collections.abc import Callable from modelx.core.base import ( @@ -169,7 +170,6 @@ def execute(self, *args, **kwargs): func = self.formula.func # Create a new function with the correct globals - import types new_func = types.FunctionType( func.__code__, namespace, diff --git a/modelx/export/exporter.py b/modelx/export/exporter.py index acb01c90..8c636dba 100644 --- a/modelx/export/exporter.py +++ b/modelx/export/exporter.py @@ -18,6 +18,7 @@ import textwrap import types import pprint +import inspect try: from functools import cached_property except ImportError: # - Python 3.7 @@ -659,8 +660,13 @@ def macro_defs(self): if formula and formula.source: result.append(formula.source) else: - # If no source, create a stub - result.append(f"def {name}():\n pass") + # If no source, create a stub with original signature + try: + sig = inspect.signature(formula.func if formula else lambda: None) + result.append(f"def {name}{sig}:\n pass") + except (ValueError, TypeError): + # Fallback if signature inspection fails + result.append(f"def {name}():\n pass") return "\n\n".join(result) From 5d8c0171acb773040cdd40bcdbeafde308b9224b Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 7 Dec 2025 13:08:08 +0000 Subject: [PATCH 5/8] Fix macro validation and improve error handling consistency Co-authored-by: fumitoh <8450892+fumitoh@users.noreply.github.com> --- modelx/core/macro.py | 5 ++++- modelx/core/model.py | 2 ++ modelx/export/exporter.py | 3 +++ 3 files changed, 9 insertions(+), 1 deletion(-) diff --git a/modelx/core/macro.py b/modelx/core/macro.py index e2b48532..24aa64b6 100644 --- a/modelx/core/macro.py +++ b/modelx/core/macro.py @@ -35,7 +35,10 @@ def __call__(self, func): def create_or_change_macro(self, func): self.name = func.__name__ if self.name is None else self.name - if is_valid_name(self.name) and self.name in self.model.macros: + if not is_valid_name(self.name): + raise ValueError(f"Invalid macro name: {self.name}") + + if self.name in self.model.macros: # Update existing macro macro = self.model.macros[self.name] macro.set_formula(func) diff --git a/modelx/core/model.py b/modelx/core/model.py index 4bd656b4..b148c6fb 100644 --- a/modelx/core/model.py +++ b/modelx/core/model.py @@ -1246,6 +1246,8 @@ def new_macro(self, name, formula): Returns: MacroImpl instance """ + if not is_valid_name(name): + raise ValueError(f"Invalid macro name: {name}") if name in self._macros: raise ValueError(f"Macro '{name}' already exists") if name in self.spaces or name in self.global_refs: diff --git a/modelx/export/exporter.py b/modelx/export/exporter.py index 8c636dba..8c815e76 100644 --- a/modelx/export/exporter.py +++ b/modelx/export/exporter.py @@ -666,6 +666,9 @@ def macro_defs(self): result.append(f"def {name}{sig}:\n pass") except (ValueError, TypeError): # Fallback if signature inspection fails + # Note: This creates a parameter-less stub, which may not match + # the original function signature. This is a known limitation + # when source code is unavailable (e.g., in REPL contexts). result.append(f"def {name}():\n pass") return "\n\n".join(result) From 5e860bfdd191d24bf69943a961000ddf15633fbf Mon Sep 17 00:00:00 2001 From: Fumito Hamamura Date: Mon, 8 Dec 2025 20:11:30 +0900 Subject: [PATCH 6/8] Implement new_macro method and corresponding tests for macro creation --- modelx/core/model.py | 80 +++++++++++++++++++++++++++ modelx/tests/core/model/test_macro.py | 70 +++++++++++++++++++++++ 2 files changed, 150 insertions(+) diff --git a/modelx/core/model.py b/modelx/core/model.py index b148c6fb..b3507f97 100644 --- a/modelx/core/model.py +++ b/modelx/core/model.py @@ -337,6 +337,86 @@ def close(self): """Close the model.""" self._impl.system.close_model(self._impl) + def new_macro(self, name=None, formula=None): + """Create a new :class:`~modelx.core.macro.Macro` in this model. + + Creates a macro that acts as a callable Python function saved within + the model. Macros share a dedicated global namespace that includes + the model itself as both ``mx_model`` and by the model's name. + + Args: + name (str, optional): Name for the macro. If omitted and a function + is provided, the function's name is used. If the function name + is not valid for a macro name, an error is raised. Must be a + valid Python identifier, and must not start with an underscore. + formula (callable, optional): The function definition. Can be: + + * A Python function (def or lambda) + * None to create an empty macro (not recommended) + + Returns: + :class:`~modelx.core.macro.Macro`: The newly created macro object + + Example: + Creating a macro using new_macro:: + + >>> model = mx.new_model('MyModel') + + >>> def get_model_info(): + ... return f"Model: {mx_model.name}" + + >>> model.new_macro(formula=get_model_info) + + + >>> model.get_model_info() + 'Model: MyModel' + + Above is equivalent to creating a macro using the decorator:: + + >>> @mx.defmacro + ... def get_model_info(): + ... return f"Model: {mx_model.name}" + + + Creating a macro with a custom name from a lambda function:: + + >>> model.new_macro('double', lambda x: x * 2) + + + >>> model.double(5) + 10 + + Macros can call other macros in the same model:: + + >>> @mx.defmacro + ... def helper(): + ... return 42 + + >>> @mx.defmacro + ... def main(): + ... return helper() * 2 + + >>> model.main() + 84 + + See Also: + * :func:`~modelx.defmacro`: Decorator to create macros + * :attr:`~modelx.core.model.Model.macros`: Access all macros + * :meth:`~modelx.core.model.Model.export`: Export model with macros + + .. versionadded:: 0.30.0 + """ + if formula is None: + raise ValueError("formula must be provided") + + if name is None: + if hasattr(formula, '__name__'): + name = formula.__name__ + else: + raise ValueError("name must be provided when formula has no __name__") + + return self._impl.new_macro(name, formula).interface + @Interface.doc.setter def doc(self, value): self._impl.doc = value diff --git a/modelx/tests/core/model/test_macro.py b/modelx/tests/core/model/test_macro.py index 3aeb3bcb..5e381521 100644 --- a/modelx/tests/core/model/test_macro.py +++ b/modelx/tests/core/model/test_macro.py @@ -250,3 +250,73 @@ def dup_name(): assert len(m.macros) == 1 m.close() + + +def test_new_macro_basic(simple_model): + """Test basic macro creation using new_macro""" + m = simple_model + + def get_name(): + return mx_model.name + + macro = m.new_macro(formula=get_name) + + assert 'get_name' in m.macros + assert macro is m.macros['get_name'] + assert m.get_name() == 'TestModel' + + +def test_new_macro_with_name(simple_model): + """Test macro creation with custom name using new_macro""" + m = simple_model + + def original_func(): + return mx_model.name + + macro = m.new_macro(name='custom_name', formula=original_func) + + assert 'custom_name' in m.macros + assert macro is m.macros['custom_name'] + assert m.custom_name() == 'TestModel' + + +def test_new_macro_with_params(simple_model): + """Test macro with parameters using new_macro""" + m = simple_model + + macro = m.new_macro('add_numbers', lambda a, b: a + b) + + assert m.add_numbers(2, 3) == 5 + assert m.add_numbers(10, 20) == 30 + + +def test_new_macro_no_formula_error(simple_model): + """Test that new_macro raises error when formula is None""" + m = simple_model + + with pytest.raises(ValueError, match="formula must be provided"): + m.new_macro(name='test') + + +def test_new_macro_no_name_error(simple_model): + """Test that new_macro raises error when name is None and formula has no __name__""" + m = simple_model + + # Create a callable object without __name__ + class CallableWithoutName: + def __call__(self): + return 42 + + with pytest.raises(ValueError, match="name must be provided"): + m.new_macro(formula=CallableWithoutName()) + + +def test_new_macro_lambda_with_name(simple_model): + """Test that new_macro works with lambda and explicit name""" + m = simple_model + + macro = m.new_macro('my_lambda', lambda x: x * 3) + + assert 'my_lambda' in m.macros + assert m.my_lambda(5) == 15 + From f1e7719791e0a5e789e948f9e22843dced665c89 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 8 Dec 2025 11:27:08 +0000 Subject: [PATCH 7/8] Add Macro documentation to reference guide - List defmacro in Creating objects section in functions.rst - Add Model.macros to Model properties in model.rst - Add Macro operations section with new_macro in model.rst - Create macro.rst documenting the Macro class - Add macro.rst to index.rst toctree Co-authored-by: fumitoh <8450892+fumitoh@users.noreply.github.com> --- doc/source/reference/functions.rst | 1 + doc/source/reference/index.rst | 1 + doc/source/reference/macro.rst | 17 +++++++++++++++++ doc/source/reference/model.rst | 11 +++++++++++ 4 files changed, 30 insertions(+) create mode 100644 doc/source/reference/macro.rst diff --git a/doc/source/reference/functions.rst b/doc/source/reference/functions.rst index fcaf6b04..a9407d09 100644 --- a/doc/source/reference/functions.rst +++ b/doc/source/reference/functions.rst @@ -36,6 +36,7 @@ Creating objects ~new_model ~new_space ~defcells + ~defmacro ~uncached ~cached diff --git a/doc/source/reference/index.rst b/doc/source/reference/index.rst index 4669db6b..2de4e16b 100644 --- a/doc/source/reference/index.rst +++ b/doc/source/reference/index.rst @@ -13,6 +13,7 @@ Reference Guide model space/index cells + macro reference view iospec diff --git a/doc/source/reference/macro.rst b/doc/source/reference/macro.rst new file mode 100644 index 00000000..2ad6d6a8 --- /dev/null +++ b/doc/source/reference/macro.rst @@ -0,0 +1,17 @@ +Macro +======================= + +.. currentmodule:: modelx.core.macro + +.. autoclass:: Macro + + +Basic properties +---------------- + +.. autosummary:: + :toctree: generated/ + :template: mxbase.rst + + ~Macro.formula + ~Macro.parent diff --git a/doc/source/reference/model.rst b/doc/source/reference/model.rst index 240f95eb..b5b77cb8 100644 --- a/doc/source/reference/model.rst +++ b/doc/source/reference/model.rst @@ -23,6 +23,7 @@ Model properties ~Model.properties ~Model.spaces ~Model.refs + ~Model.macros ~Model.iospecs ~Model.tracegraph @@ -38,6 +39,16 @@ Model operations ~Model.set_property +Macro operations +---------------- + +.. autosummary:: + :toctree: generated/ + :template: mxbase.rst + + ~Model.new_macro + + Saving operations ----------------- From c600163f685fc4affb2d4f776a53d2073d709628 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 8 Dec 2025 13:33:13 +0000 Subject: [PATCH 8/8] Use _name instead of name property in macros - Remove name property from BaseParent in _mx_sys.py - Update all macro code and tests to use mx_model._name instead of mx_model.name - Update docstrings in api.py, macro.py, and model.py to use _name - All tests pass successfully Co-authored-by: fumitoh <8450892+fumitoh@users.noreply.github.com> --- modelx/core/api.py | 4 ++-- modelx/core/macro.py | 2 +- modelx/core/model.py | 6 +++--- modelx/export/_mx_sys.py | 5 ----- modelx/tests/core/model/test_macro.py | 10 +++++----- modelx/tests/export/test_export_macros.py | 2 +- 6 files changed, 12 insertions(+), 17 deletions(-) diff --git a/modelx/core/api.py b/modelx/core/api.py index 9b81e20e..13932ae6 100644 --- a/modelx/core/api.py +++ b/modelx/core/api.py @@ -302,7 +302,7 @@ def defmacro(model=None, name=None, *funcs): >>> @mx.defmacro ... def get_model_name(): - ... return mx_model.name + ... return mx_model._name >>> get_model_name @@ -331,7 +331,7 @@ def defmacro(model=None, name=None, *funcs): Creates multiple macros from multiple function definitions:: def foo(): - return mx_model.name + return mx_model._name def bar(): return foo() diff --git a/modelx/core/macro.py b/modelx/core/macro.py index 24aa64b6..46191480 100644 --- a/modelx/core/macro.py +++ b/modelx/core/macro.py @@ -64,7 +64,7 @@ class Macro(Interface, Callable): >>> @mx.defmacro ... def get_model_name(): - ... return mx_model.name + ... return mx_model._name >>> @mx.defmacro(model=m, name='print_name') ... def print_model_name(message): diff --git a/modelx/core/model.py b/modelx/core/model.py index b3507f97..a18171e9 100644 --- a/modelx/core/model.py +++ b/modelx/core/model.py @@ -363,7 +363,7 @@ def new_macro(self, name=None, formula=None): >>> model = mx.new_model('MyModel') >>> def get_model_info(): - ... return f"Model: {mx_model.name}" + ... return f"Model: {mx_model._name}" >>> model.new_macro(formula=get_model_info) @@ -375,7 +375,7 @@ def new_macro(self, name=None, formula=None): >>> @mx.defmacro ... def get_model_info(): - ... return f"Model: {mx_model.name}" + ... return f"Model: {mx_model._name}" Creating a macro with a custom name from a lambda function:: @@ -602,7 +602,7 @@ def macros(self): >>> @mx.defmacro ... def get_name(): - ... return mx_model.name + ... return mx_model._name >>> m.macros {'get_name': } diff --git a/modelx/export/_mx_sys.py b/modelx/export/_mx_sys.py index 2e033b2c..feb84768 100644 --- a/modelx/export/_mx_sys.py +++ b/modelx/export/_mx_sys.py @@ -21,11 +21,6 @@ class BaseParent(BaseMxObject): _parent: 'BaseParent' _model: 'BaseModel' _name: str - - @property - def name(self): - """Return the name of this object""" - return self._name def _mx_walk(self, skip_self: bool = False): """Generator yielding spaces in breadth-first order""" diff --git a/modelx/tests/core/model/test_macro.py b/modelx/tests/core/model/test_macro.py index 5e381521..9ca51243 100644 --- a/modelx/tests/core/model/test_macro.py +++ b/modelx/tests/core/model/test_macro.py @@ -17,7 +17,7 @@ def test_defmacro_basic(simple_model): @mx.defmacro def get_name(): - return mx_model.name + return mx_model._name assert 'get_name' in m.macros assert get_name is m.macros['get_name'] @@ -30,7 +30,7 @@ def test_defmacro_with_args(simple_model): @mx.defmacro(model=m, name='custom_name') def original_name(): - return mx_model.name + return mx_model._name assert 'custom_name' in m.macros assert original_name is m.macros['custom_name'] @@ -75,7 +75,7 @@ def test_macro_access_model_as_mx_model(): @mx.defmacro def get_model_via_mx_model(): - return mx_model.name + return mx_model._name assert m.get_model_via_mx_model() == 'AccessTest' @@ -257,7 +257,7 @@ def test_new_macro_basic(simple_model): m = simple_model def get_name(): - return mx_model.name + return mx_model._name macro = m.new_macro(formula=get_name) @@ -271,7 +271,7 @@ def test_new_macro_with_name(simple_model): m = simple_model def original_func(): - return mx_model.name + return mx_model._name macro = m.new_macro(name='custom_name', formula=original_func) diff --git a/modelx/tests/export/test_export_macros.py b/modelx/tests/export/test_export_macros.py index a5e4faec..19a68f44 100644 --- a/modelx/tests/export/test_export_macros.py +++ b/modelx/tests/export/test_export_macros.py @@ -12,7 +12,7 @@ def macro_model(): @mx.defmacro def get_model_name(): - return mx_model.name + return mx_model._name @mx.defmacro def add_numbers(a, b):