Repository: sympy/sympy
File: sympy/core/function.py (Python)

Code before:
    Note that assumptions on a function are unrelated to the assumptions on
    the variables it is called on. If you want to add a relationship, subclass
    ``Function`` and define custom assumptions handler methods. See the
    :ref:`custom-functions-assumptions` section of the :ref:`custom-functions`
    guide for more details.

    **Custom Function Subclasses**

    The :ref:`custom-functions` guide has several
    :ref:`custom-functions-complete-examples` of how to subclass ``Function``
    to create a custom function.

    """

    @property
    def _diff_wrt(self):
        return False

    if TYPE_CHECKING:
        # __new__ is typed to return __new__ because of default base classes.
        # But the actual type is more precise and should be callable
        # Python doesn't have generic base classes
        def __call__(self, *args, **options): ...

    @cacheit
    def __new__(cls, *args, **options) -> type[AppliedUndef]:  # type: ignore
        # Handle calls like Function('f')
        if cls is Function:
            return UndefinedFunction(*args, **options)  # type: ignore
        else:
            return cls._new_(*args, **options)  # type: ignore

    @classmethod
    def _new_(cls, *args, **options) -> Expr:
        n = len(args)

        if not cls._valid_nargs(n):
            # XXX: exception message must be in exactly this format to
            # make it work with NumPy's functions like vectorize(). See,
            # for example, https://github.com/numpy/numpy/issues/1697.
            # The ideal solution would be just to attach metadata to
            # the exception and change NumPy to take advantage of this.
            temp = ('%(name)s takes %(qual)s %(args)s '
                   'argument%(plural)s (%(given)s given)')
            raise TypeError(temp % {
                'name': cls,
                'qual': 'exactly' if len(cls.nargs) == 1 else 'at least',
                'args': min(cls.nargs),
                'plural': 's'*(min(cls.nargs) != 1),
                'given': n})


Review comment (suggestion):
I'm not sure I understand what this `__call__` method is doing.

The `Function` class does not have a `__call__` method:
```python
In [1]: function = sin(x)

In [2]: function()
---------------------------------------------------------------------------
TypeError                                 Traceback (most recent call last)
Cell In[2], line 1
----> 1 function()

TypeError: 'sin' object is not callable
```
The `__new__` method here is correctly typed as returning `type[AppliedUndef]` which is callable. So `Function('x')` returns `type[AppliedUndef]` and `AppliedUndef.__new__` returns `Expr`.

Subclasses of `Function` should now subclass `DefinedFunction` whose `__new__` returns `Expr`.

See the explanation in gh-25103.

Code after:
    Note that assumptions on a function are unrelated to the assumptions on
    the variables it is called on. If you want to add a relationship, subclass
    ``Function`` and define custom assumptions handler methods. See the
    :ref:`custom-functions-assumptions` section of the :ref:`custom-functions`
    guide for more details.

    **Custom Function Subclasses**

    The :ref:`custom-functions` guide has several
    :ref:`custom-functions-complete-examples` of how to subclass ``Function``
    to create a custom function.

    """

    @property
    def _diff_wrt(self):
        return False

    @cacheit
    def __new__(cls, *args, **options) -> type[AppliedUndef]:  # type: ignore
        # Handle calls like Function('f')
        if cls is Function:
            return UndefinedFunction(*args, **options)  # type: ignore
        else:
            return cls._new_(*args, **options)  # type: ignore

    @classmethod
    def _new_(cls, *args, **options) -> Expr:
        n = len(args)

        if not cls._valid_nargs(n):
            # XXX: exception message must be in exactly this format to
            # make it work with NumPy's functions like vectorize(). See,
            # for example, https://github.com/numpy/numpy/issues/1697.
            # The ideal solution would be just to attach metadata to
            # the exception and change NumPy to take advantage of this.
            temp = ('%(name)s takes %(qual)s %(args)s '
                   'argument%(plural)s (%(given)s given)')
            raise TypeError(temp % {
                'name': cls,
                'qual': 'exactly' if len(cls.nargs) == 1 else 'at least',
                'args': min(cls.nargs),
                'plural': 's'*(min(cls.nargs) != 1),
                'given': n})

        evaluate = options.get('evaluate', global_parameters.evaluate)
        result = super().__new__(cls, *args, **options)
        if evaluate and isinstance(result, cls) and result.args:
            _should_evalf = [cls._should_evalf(a) for a in result.args]
            pr2 = min(_should_evalf)
            if pr2 > 0: