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

Issue:
[BUG] JobQueue.set_application drop jobstores field
### Steps to Reproduce

1. Configure JobQueue:
```python
job_queue = JobQueue()
job_queue.scheduler.configure(jobstores={'default': MongoDBJobStore()})
```
2. Build app:
```python
application = ApplicationBuilder().bot(tg_bot).job_queue(job_queue).build()
```

### Expected behaviour

`jobstores` should should not be dropped.

### Actual behaviour

After building JobQueue, jobstores are dropped.

### Operating System

linux

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

```shell
python-telegram-bot 20.4 (2.0.3-1-g51cab98)
Bot API 6.7
Python 3.11.3 (main, Jun  5 2023, 09:32:32) [GCC 13.1.1 20230429]
```


### Relevant log output

_No response_

### Additional Context

_No response_


Discussion:
Hi. The issue apparently is that [`JobQueue.set_application`](https://github.com/python-telegram-bot/python-telegram-bot/blob/v20.4/telegram/ext/_jobqueue.py#L137-L152) calls `job_queue.scheduler.configure` and that this apparently completely overrides the existing configuration instead of adding to it. At least I haven't found any mention in the [aps docs](https://apscheduler.readthedocs.io/en/3.x/modules/schedulers/base.html#apscheduler.schedulers.base.BaseScheduler.configure) on how to keep the current settings untouched.

A quick workaround would be 
```python
application = ApplicationBuilder().bot(tg_bot).build()
application.job_queue.add_jobstore(MongoDBJobStore()))
```

I do however agree that we should look into how this can be made more convenient for the (PTB) user or at the very least document this issue.
job_queue = JobQueue()
job_queue.scheduler.configure(jobstores={'default': MongoDBJobStore()})
Had another look at this. `BaseScheduler.configure` does indeed actively delete all current settings, see e.g.:

https://github.com/agronholm/apscheduler/blob/677ce711a5381c3d44f4c6313098844d26b70666/apscheduler/schedulers/base.py#L746-L747

Unfortunately, there is also no documented way to retrieve the current values of these settings (e.g. `BaseScheduler._jobstores` is protected so writing a wrapper that merges existing config values with new ones is not easy.

I would hence suggest that we simply add a warning to `JobStore.scheduler` and advice to use `BaseScheduler.add/remove_*` rather than `BaseScheduler.configure`.

@python-telegram-bot/developers would you be okay with that?
Not sure a warning suffices, because we will always override configure. I'd prefere to not allow this Can we subclass and deny setting it via setter? And maybe ask via an issue to get config in the future?
Sorry, I don't quite understand your proposed solution. Which setter do you mean? What would you override in a subclass?
I'm not sure if asking for a change upstream is worth it, as there already are v4 pre-releases and I would imagine that the 3.x branch only gets big fixes now ...
Wait nevermind, how would you implement a warning anyway at .scheduler? That is not our class, thats apscheduler
I meant a simple warning in the docs of JobQueue.scheduler :D 
PS: One could ofc make `.scheduler` a property and issue the warning every time it's accessed - but that would be a bit of overkill I guess  

Resolving patch:
diff --git a/telegram/ext/_jobqueue.py b/telegram/ext/_jobqueue.py
--- a/telegram/ext/_jobqueue.py
+++ b/telegram/ext/_jobqueue.py
@@ -76,6 +76,17 @@ class JobQueue(Generic[CCT]):
     Attributes:
         scheduler (:class:`apscheduler.schedulers.asyncio.AsyncIOScheduler`): The scheduler.
 
+            Warning:
+                This scheduler is configured by :meth:`set_application`. Additional configuration
+                settings can be made by users. However, calling
+                :meth:`~apscheduler.schedulers.base.BaseScheduler.configure` will delete any
+                previous configuration settings. Therefore, please make sure to pass the values
+                returned by :attr:`scheduler_configuration` to the method call in addition to your
+                custom values.
+                Alternatively, you can also use methods like
+                :meth:`~apscheduler.schedulers.base.BaseScheduler.add_jobstore` to avoid using
+                :meth:`~apscheduler.schedulers.base.BaseScheduler.configure` altogether.
+
             .. versionchanged:: 20.0
                 Uses :class:`~apscheduler.schedulers.asyncio.AsyncIOScheduler` instead of
                 :class:`~apscheduler.schedulers.background.BackgroundScheduler`
@@ -94,9 +105,7 @@ def __init__(self) -> None:
 
         self._application: Optional[weakref.ReferenceType[Application]] = None
         self._executor = AsyncIOExecutor()
-        self.scheduler: AsyncIOScheduler = AsyncIOScheduler(
-            timezone=pytz.utc, executors={"default": self._executor}
-        )
+        self.scheduler: AsyncIOScheduler = AsyncIOScheduler(**self.scheduler_configuration)
 
     def __repr__(self) -> str:
         """Give a string representation of the JobQueue in the form ``JobQueue[application=...]``.
@@ -119,6 +128,43 @@ def application(self) -> "Application[Any, CCT, Any, Any, Any, JobQueue[CCT]]":
             return application
         raise RuntimeError("The application instance is no longer alive.")
 
+    @property
+    def scheduler_configuration(self) -> JSONDict:
+        """Provides configuration values that are used by :class:`JobQueue` for :attr:`scheduler`.
+
+        Tip:
+            Since calling
+            :meth:`scheduler.configure() <apscheduler.schedulers.base.BaseScheduler.configure>`
+            deletes any previous setting, please make sure to pass these values to the method call
+            in addition to your custom values:
+
+            .. code-block:: python
+
+                scheduler.configure(..., **job_queue.scheduler_configuration)
+
+            Alternatively, you can also use methods like
+            :meth:`~apscheduler.schedulers.base.BaseScheduler.add_jobstore` to avoid using
+            :meth:`~apscheduler.schedulers.base.BaseScheduler.configure` altogether.
+
+        .. versionadded:: NEXT.VERSION
+
+        Returns:
+            Dict[:obj:`str`, :obj:`object`]: The configuration values as dictionary.
+
+        """
+        timezone: object = pytz.utc
+        if (
+            self._application
+            and isinstance(self.application.bot, ExtBot)
+            and self.application.bot.defaults
+        ):
+            timezone = self.application.bot.defaults.tzinfo or pytz.utc
+
+        return {
+            "timezone": timezone,
+            "executors": {"default": self._executor},
+        }
+
     def _tz_now(self) -> datetime.datetime:
         return datetime.datetime.now(self.scheduler.timezone)
 
@@ -166,11 +212,7 @@ def set_application(
 
         """
         self._application = weakref.ref(application)
-        if isinstance(application.bot, ExtBot) and application.bot.defaults:
-            self.scheduler.configure(
-                timezone=application.bot.defaults.tzinfo or pytz.utc,
-                executors={"default": self._executor},
-            )
+        self.scheduler.configure(**self.scheduler_configuration)
 
     @staticmethod
     async def job_callback(job_queue: "JobQueue[CCT]", job: "Job[CCT]") -> None:


Test patch:
diff --git a/tests/ext/test_jobqueue.py b/tests/ext/test_jobqueue.py
--- a/tests/ext/test_jobqueue.py
+++ b/tests/ext/test_jobqueue.py
@@ -25,7 +25,7 @@
 
 import pytest
 
-from telegram.ext import ApplicationBuilder, CallbackContext, ContextTypes, Job, JobQueue
+from telegram.ext import ApplicationBuilder, CallbackContext, ContextTypes, Defaults, Job, JobQueue
 from telegram.warnings import PTBUserWarning
 from tests.auxil.envvars import GITHUB_ACTION, TEST_WITH_OPT_DEPS
 from tests.auxil.pytest_classes import make_bot
@@ -106,6 +106,15 @@ def _reset(self):
         self.job_time = 0
         self.received_error = None
 
+    def test_scheduler_configuration(self, job_queue, timezone, bot):
+        # Unfortunately, we can't really test the executor setting explicitly without relying
+        # on protected attributes. However, this should be tested enough implicitly via all the
+        # other tests in here
+        assert job_queue.scheduler_configuration["timezone"] is UTC
+
+        tz_app = ApplicationBuilder().defaults(Defaults(tzinfo=timezone)).token(bot.token).build()
+        assert tz_app.job_queue.scheduler_configuration["timezone"] is timezone
+
     async def job_run_once(self, context):
         if (
             isinstance(context, CallbackContext)
@@ -578,7 +587,7 @@ async def test_process_error_that_raises_errors(self, job_queue, app, caplog):
         assert rec.name == "telegram.ext.Application"
         assert "No error handlers are registered" in rec.getMessage()
 
-    async def test_custom_context(self, bot, job_queue):
+    async def test_custom_context(self, bot):
         application = (
             ApplicationBuilder()
             .token(bot.token)
@@ -589,6 +598,7 @@ async def test_custom_context(self, bot, job_queue):
             )
             .build()
         )
+        job_queue = JobQueue()
         job_queue.set_application(application)
 
         async def callback(context):
@@ -599,9 +609,11 @@ async def callback(context):
                 type(context.bot_data),
             )
 
+        await job_queue.start()
         job_queue.run_once(callback, 0.1)
         await asyncio.sleep(0.15)
         assert self.result == (CustomContext, None, None, int)
+        await job_queue.stop()
 
     async def test_attribute_error(self):
         job = Job(self.job_run_once)