Repository: python-telegram-bot/python-telegram-bot

Issue:
[BUG] telegram.ext._utils.stack.was_called_by gives incorrect result on 64-bit machine
### Steps to Reproduce

1. Set up virtualenv using `python3 -m venv` on a 64-bit machine.
2. Initialize an `application` object using `telegram.ext.ApplicationBuilder`:
``` python
from telegram.ext import ApplicationBuilder
def main() -> None:
    application = ApplicationBuilder().token("TOKEN").build()

if __name__ == "__main__":
    main()
```
4. Run the bot in virtualenv and it will give a warning messgae like ```PTBUserWarning: `Application` instances should be built via the `ApplicationBuilder`.```


### Expected behaviour

The warning message shouldn't be given since `ApplicationBuilder` is being used.

### Actual behaviour

``` bash
$ python test.py 
../venv/lib64/python3.11/site-packages/telegram/ext/_applicationbuilder.py:292: 
PTBUserWarning: `Application` instances should be built via the `ApplicationBuilder`.
  ] = DefaultValue.get_value(  # pylint: disable=not-callable
```



### Operating System

Fedora Linux 37 (Server Edition)

### Version of Python, python-telegram-bot & dependencies

```shell
python-telegram-bot 20.0
Bot API 6.4
Python 3.11.1 (main, Dec  7 2022, 00:00:00) [GCC 12.2.1 20221121 (Red Hat 12.2.1-4)]
```


### Relevant log output

_No response_

### Additional Context

I believe this is caused by comparing a resolved path with an unresolved path [here](https://github.com/python-telegram-bot/python-telegram-bot/blob/master/telegram/ext/_application.py#L273). 

In my case, it finds `../venv/lib/python3.11/site-packages/telegram/ext/_applicationbuilder.py` not equal to `../venv/lib64/python3.11/site-packages/telegram/ext/_applicationbuilder.py`, the directory `lib64` being a symlink to `lib`.

A quick (maybe not final) fix is to modify [stack.py](https://github.com/python-telegram-bot/python-telegram-bot/blob/master/telegram/ext/_utils/stack.py) so that `was_called_by` always resolves paths from frame:
``` python
    while frame.f_back:
        frame = frame.f_back
        if Path(frame.f_code.co_filename).resolve() == caller:
            return True
```
I have tested it and the warning no longer appears.


Discussion:
Hi. Thanks for the report!
Resolving the path sounds like a good idea to me. Would you like to PR yourself?
Unit testing that may get complicated, but maybe one can create a temporary directory and a symlink within that or something like that? 🤔 I'd be okay with not having a unit test, I guess …

Resolving patch:
diff --git a/telegram/ext/_utils/stack.py b/telegram/ext/_utils/stack.py
--- a/telegram/ext/_utils/stack.py
+++ b/telegram/ext/_utils/stack.py
@@ -25,10 +25,13 @@
     user. Changes to this module are not considered breaking changes and may not be documented in
     the changelog.
 """
+import logging
 from pathlib import Path
 from types import FrameType
 from typing import Optional
 
+_logger = logging.getLogger(__name__)
+
 
 def was_called_by(frame: Optional[FrameType], caller: Path) -> bool:
     """Checks if the passed frame was called by the specified file.
@@ -51,11 +54,22 @@ def was_called_by(frame: Optional[FrameType], caller: Path) -> bool:
     if frame is None:
         return False
 
+    try:
+        return _was_called_by(frame, caller)
+    except Exception as exc:
+        _logger.debug(
+            "Failed to check if frame was called by `caller`. Assuming that it was not.",
+            exc_info=exc,
+        )
+        return False
+
+
+def _was_called_by(frame: FrameType, caller: Path) -> bool:
     # https://stackoverflow.com/a/57712700/10606962
-    if Path(frame.f_code.co_filename) == caller:
+    if Path(frame.f_code.co_filename).resolve() == caller:
         return True
     while frame.f_back:
         frame = frame.f_back
-        if Path(frame.f_code.co_filename) == caller:
+        if Path(frame.f_code.co_filename).resolve() == caller:
             return True
     return False


Test patch:
diff --git a/tests/ext/_utils/test_stack.py b/tests/ext/_utils/test_stack.py
--- a/tests/ext/_utils/test_stack.py
+++ b/tests/ext/_utils/test_stack.py
@@ -17,19 +17,100 @@
 # You should have received a copy of the GNU Lesser Public License
 # along with this program.  If not, see [http://www.gnu.org/licenses/].
 import inspect
+import logging
+import sys
 from pathlib import Path
 
+import pytest
+
 from telegram.ext._utils.stack import was_called_by
 
 
+def symlink_to(source: Path, target: Path) -> None:
+    """Wrapper around Path.symlink_to that pytest-skips OS Errors.
+    Useful e.g. for making tests not fail locally due to permission errors.
+    """
+    try:
+        source.symlink_to(target)
+    except OSError as exc:
+        pytest.skip(f"Skipping due to OS error while creating symlink: {exc!r}")
+
+
 class TestStack:
     def test_none_input(self):
         assert not was_called_by(None, None)
 
     def test_called_by_current_file(self):
+        # Testing a call by a different file is somewhat hard but it's covered in
+        # TestUpdater/Application.test_manual_init_warning
         frame = inspect.currentframe()
         file = Path(__file__)
         assert was_called_by(frame, file)
 
-    # Testing a call by a different file is somewhat hard but it's covered in
-    # TestUpdater/Application.test_manual_init_warning
+    def test_exception(self, monkeypatch, caplog):
+        def resolve(self):
+            raise RuntimeError("Can Not Resolve")
+
+        with caplog.at_level(logging.DEBUG):
+            monkeypatch.setattr(Path, "resolve", resolve)
+            assert not was_called_by(inspect.currentframe(), None)
+
+        assert len(caplog.records) == 1
+        assert caplog.records[0].levelno == logging.DEBUG
+        assert caplog.records[0].getMessage().startswith("Failed to check")
+        assert caplog.records[0].exc_info[0] is RuntimeError
+        assert "Can Not Resolve" in str(caplog.records[0].exc_info[1])
+
+    def test_called_by_symlink_file(self, tmp_path):
+        # Set up a call from a linked file in a temp directory,
+        # then test it with its resolved path.
+        # Here we expect `was_called_by` to recognize
+        # "`tmp_path`/caller_link.py" as same as "`tmp_path`/caller.py".
+        temp_file = tmp_path / "caller.py"
+        caller_content = """
+import inspect
+def caller_func():
+    return inspect.currentframe()
+        """
+        with temp_file.open("w") as f:
+            f.write(caller_content)
+
+        symlink_file = tmp_path / "caller_link.py"
+        symlink_to(symlink_file, temp_file)
+
+        sys.path.append(tmp_path.as_posix())
+        from caller_link import caller_func
+
+        frame = caller_func()
+        assert was_called_by(frame, temp_file)
+
+    def test_called_by_symlink_file_nested(self, tmp_path):
+        # Same as test_called_by_symlink_file except
+        # inner_func is nested inside outer_func to test
+        # if `was_called_by` can resolve paths in recursion.
+        temp_file1 = tmp_path / "inner.py"
+        inner_content = """
+import inspect
+def inner_func():
+    return inspect.currentframe()
+        """
+        with temp_file1.open("w") as f:
+            f.write(inner_content)
+
+        temp_file2 = tmp_path / "outer.py"
+        outer_content = """
+from inner import inner_func
+def outer_func():
+    return inner_func()
+        """
+        with temp_file2.open("w") as f:
+            f.write(outer_content)
+
+        symlink_file2 = tmp_path / "outer_link.py"
+        symlink_to(symlink_file2, temp_file2)
+
+        sys.path.append(tmp_path.as_posix())
+        from outer_link import outer_func
+
+        frame = outer_func()
+        assert was_called_by(frame, temp_file2)