Repository: Pyomo/pyomo

Issue:
pprint does not work with external functions
```
import pyomo.environ as pe
from pyomo.common.getGSL import find_GSL

m = pe.ConcreteModel()
m.hypot = pe.ExternalFunction(library=find_GSL(), function='gsl_hypot')
m.x = pe.Var(initialize=0.5)
m.y = pe.Var(initialize=0.5)
m.c = pe.Constraint(expr=2 * m.hypot(m.x, m.y) <= 10)
m.pprint()
```

Produces the following output/exception

```
2 Var Declarations
    x : Size=1, Index=None
        Key  : Lower : Value : Upper : Fixed : Stale : Domain
        None :  None :   0.5 :  None : False : False :  Reals
    y : Size=1, Index=None
        Key  : Lower : Value : Upper : Fixed : Stale : Domain
        None :  None :   0.5 :  None : False : False :  Reals

1 Constraint Declarations
    c : Size=1, Index=None, Active=True
        Key  : Lower : Body          : Upper : Active
        None :  -Inf : 2*hypot(x, y) :  10.0 :   True

1 ExternalFunction Declarations
Traceback (most recent call last):
  File "blah.py", line 10, in <module>
    m.pprint()
  File "/.../pyomo/core/base/component.py", line 495, in pprint
    self.is_constructed(), *self._pprint()
  File "/.../pyomo/core/base/component.py", line 363, in _pprint_base_impl
    _fcn(ostream, _key, _data_dict[_key])
  File "/.../pyomo/core/base/block.py", line 1909, in _pprint_callback
    data._pprint_blockdata_components(ostream)
  File "/.../pyomo/core/base/block.py", line 1676, in _pprint_blockdata_components
    self.component(key).pprint(ostream=indented_ostream)
  File "/.../pyomo/core/base/component.py", line 495, in pprint
    self.is_constructed(), *self._pprint()
AttributeError: 'AMPLExternalFunction' object has no attribute '_pprint'
```


Discussion:

Resolving patch:
diff --git a/pyomo/core/base/external.py b/pyomo/core/base/external.py
--- a/pyomo/core/base/external.py
+++ b/pyomo/core/base/external.py
@@ -3,7 +3,7 @@
 #  Pyomo: Python Optimization Modeling Objects
 #  Copyright 2017 National Technology and Engineering Solutions of Sandia, LLC
 #  Under the terms of Contract DE-NA0003525 with National Technology and 
-#  Engineering Solutions of Sandia, LLC, the U.S. Government retains certain 
+#  Engineering Solutions of Sandia, LLC, the U.S. Government retains certain
 #  rights in this software.
 #  This software is distributed under the 3-clause BSD License.
 #  ___________________________________________________________________________
@@ -55,7 +55,7 @@ def __init__(self, *args, **kwds):
         # block._add_temporary_set assumes ALL components define an
         # index.  Sigh.
         self._index = None
-        
+
     def get_units(self):
         """Return the units for this ExternalFunction"""
         return self._units
@@ -85,7 +85,7 @@ def __call__(self, *args):
                     pass
                 if not arg.__class__ in native_types and arg.is_potentially_variable():
                     pv = True
-            except AttributeError:    
+            except AttributeError:
                 args_[i] = NonNumericValue(arg)
         #
         if pv:
@@ -189,6 +189,17 @@ def atreset(ae, a, b):
         FUNCADD = CFUNCTYPE( None, POINTER(_AMPLEXPORTS) )
         FUNCADD(('funcadd_ASL', self._so))(byref(AE))
 
+    def _pprint(self):
+        return (
+            [ ('function', self._function),
+              ('library', self._library),
+              ('units', str(self._units)),
+              ('arg_units', [ str(u) for u in self._arg_units ]
+               if self._arg_units is not None else None),
+            ],
+            (), None, None
+        )
+
 
 class PythonCallbackFunction(ExternalFunction):
     global_registry = {}
@@ -238,6 +249,16 @@ def evaluate(self, args):
                 "PythonCallbackFunction called with invalid Global ID" )
         return self._fcn(*args_[1:])
 
+    def _pprint(self):
+        return (
+            [ ('function', self._fcn.__qualname__),
+              ('units', str(self._units)),
+              ('arg_units', [ str(u) for u in self._arg_units[1:] ]
+               if self._arg_units is not None else None),
+            ],
+            (), None, None
+        )
+
 
 class _ARGLIST(Structure):
     """Mock up the arglist structure from AMPL's funcadd.h


Test patch:
diff --git a/pyomo/core/tests/unit/test_external.py b/pyomo/core/tests/unit/test_external.py
--- a/pyomo/core/tests/unit/test_external.py
+++ b/pyomo/core/tests/unit/test_external.py
@@ -9,6 +9,8 @@
 #  ___________________________________________________________________________
 #
 
+from io import StringIO
+
 import pyomo.common.unittest as unittest
 
 from pyomo.common.getGSL import find_GSL
@@ -16,6 +18,7 @@
 from pyomo.core.base.external import (PythonCallbackFunction,
                                       ExternalFunction,
                                       AMPLExternalFunction)
+from pyomo.core.base.units_container import pint_available, units
 from pyomo.opt import check_available_solvers
 
 def _g(*args):
@@ -62,7 +65,34 @@ def test_extra_kwargs(self):
         with self.assertRaises(ValueError):
             m.f = ExternalFunction(_g, this_should_raise_error='foo')
 
-        
+    def test_pprint(self):
+        m = ConcreteModel()
+        m.h = ExternalFunction(_g)
+
+        out = StringIO()
+        m.pprint(ostream=out)
+        self.assertEqual(out.getvalue().strip(), """
+1 ExternalFunction Declarations
+    h : function=_g, units=None, arg_units=None
+
+1 Declarations: h
+        """.strip())
+
+        if not pint_available:
+            return
+        m.i = ExternalFunction(function=_h,
+                               units=units.kg, arg_units=[units.m, units.s])
+        out = StringIO()
+        m.pprint(ostream=out)
+        self.assertEqual(out.getvalue().strip(), """
+2 ExternalFunction Declarations
+    h : function=_g, units=None, arg_units=None
+    i : function=_h, units=kg, arg_units=['m', 's']
+
+2 Declarations: h i
+        """.strip())
+
+
 class TestAMPLExternalFunction(unittest.TestCase):
     def assertListsAlmostEqual(self, first, second, places=7, msg=None):
         self.assertEqual(len(first), len(second))
@@ -163,6 +193,33 @@ def test_clone_gsl_function(self):
         res = opt.solve(model3, tee=True)
         self.assertAlmostEqual(value(model3.o), 0.885603194411, 7)
 
+    def test_pprint(self):
+        m = ConcreteModel()
+        m.f = ExternalFunction(library="junk.so", function="junk")
+
+        out = StringIO()
+        m.pprint(ostream=out)
+        self.assertEqual(out.getvalue().strip(), """
+1 ExternalFunction Declarations
+    f : function=junk, library=junk.so, units=None, arg_units=None
+
+1 Declarations: f
+        """.strip())
+
+        if not pint_available:
+            return
+        m.g = ExternalFunction(library="junk.so", function="junk",
+                               units=units.kg, arg_units=[units.m, units.s])
+        out = StringIO()
+        m.pprint(ostream=out)
+        self.assertEqual(out.getvalue().strip(), """
+2 ExternalFunction Declarations
+    f : function=junk, library=junk.so, units=None, arg_units=None
+    g : function=junk, library=junk.so, units=kg, arg_units=['m', 's']
+
+2 Declarations: f g
+        """.strip())
+
 
 if __name__ == "__main__":
     unittest.main()