Repository: python-telegram-bot/python-telegram-bot
Pull request: Add Parameter `pattern` to `JobQueue.jobs()`
<!--
Hey! You're PRing? Cool!
Please be sure to check out our contribution guide (https://github.com/python-telegram-bot/python-telegram-bot/blob/master/.github/CONTRIBUTING.rst).
Especially, please have a look at the check list for PRs (https://github.com/python-telegram-bot/python-telegram-bot/blob/master/.github/CONTRIBUTING.rst#check-list-for-prs). Feel free to copy (parts of) the checklist to the PR description to remind you or the maintainers of open points or if you have questions on anything.
-->

Closes #4544


Problem statement:
Filter jobs with an optional parameter use_regex
### What kind of feature are you missing? Where do you notice a shortcoming of PTB?

I want be able to filter the jobs with a partial match of the job names, but the function `get_jobs_by_name` doesn't allow it, since it does an absolute comparison between the provided name and the job's name.

### Describe the solution you'd like

It would be nice to be able to filter the jobs by their names with the support of regular expressions, so developers can use the same function (`get_jobs_by_name`), extending its functionality to find jobs matching the pattern in their names.

### Describe alternatives you've considered

I considered to use another approach, like getting the list of jobs first, and then filter it with regular expressions, but that's redundant, since everytime this logic has to be applied, a filter step has to be inserted.

### Additional context

I've already implemented the functionality in my project, and it seems that it's doing its job:
```python
from re import compile # To put at the top of the module

def get_jobs_by_name(self, name: str, use_regex: bool = False) -> Tuple["Job[CCT]", ...]:
    """Returns a tuple of all *pending/scheduled* jobs with the given name that are currently
    in the :class:`JobQueue`.
        
    If :obj:`use_regex` parameter is set to :obj:`True`, re.search() function is used to look for
    a match with the provided :obj:`name` as pattern on the :obj:`job.name`.

    Returns:
        Tuple[:class:`Job`]: Tuple of all *pending* or *scheduled* jobs matching the name.
    """
    if use_regex:
        pattern = compile(str(name))
        return tuple(job for job in self.jobs() if pattern.search(job.name))
        
    return tuple(job for job in self.jobs() if job.name == name)
```
Since the new parameter `use_regex` is set to False as default, the behaviour of this function for actual uses doesn't change, therefore is not a breaking change.

Hints from issue discussion:
Hi,
thanks for reaching out! Filterung jobs by regex sounds like a reasonable use case for me, so I'm open to a contribution 👍 
Your proposed interface does work, however I'm not sure if it the most natural implementation. Having a new parameter `use_regex` modify how the `name` parameter is interpreted doesn't feel straight forward to me. Plus the `_by_name` part would then no longer be really true 😅 

What do you instead think of the following:

* add an optional paremeter `pattern` to `jobs` so that `jobs()` still returns all jobs but `jobs(pattern)` filters based on the regex
* refactor `get_jobs_by_name` by making it call `jobs(f"^{name}$")`

The filtered search would then be available through `jobs` rather than `get_jobs_by_name`.
Good day @Bibo-Joshi !
Yeah, looking at the code of get_jobs_by_name in the actual implementation, and adding the parameter that I added, I actually thought that it could generate a little bit of (justified) confusion, due to the ambigue role of name.
Let me see how can I implement your suggestion, and I'll get back to you.
In the meanwhile, have a good day!
@Bibo-Joshi This is my proposition:
```python
def jobs(self, name: str) -> Tuple["Job[CCT]", ...]:
    """Returns a tuple of all *scheduled* jobs that are currently in the :class:`JobQueue`,
    matching the :obj:`name` provided.

    Returns:
        Tuple[:class:`Job`]: Tuple of all *scheduled* jobs.
    """
    pattern = compile(str(name))
    return tuple(Job.from_aps_job(job) for job in self.scheduler.get_jobs() if pattern.search(job.name))

def get_jobs_by_name(self, name: str) -> Tuple["Job[CCT]", ...]:
    """Returns a tuple of all *pending/scheduled* jobs with the given name that are currently
    in the :class:`JobQueue`.
    Please note that the parameter :obj:`name` accepts regular expressions.

    Returns:
        Tuple[:class:`Job`]: Tuple of all *pending* or *scheduled* jobs matching the name.
    """
    return tuple(job for job in self.jobs(name=f"^{name}$"))
```

The parameter `name` added in the _jobs_ method can't be optional, otherwise all jobs would be returned.
Being said so, with the suggestion you proposed, I managed to filter the jobs providing a regular expression in the `name` parameter of the _get_jobs_by_name", keeping the logic as it was before thanks to the anchors ^ and $ inserted respectively in the `name` parameter of the _jobs_method.
Tested a little bit around, and as long as the user is not providing any strange regular expression, the result would just be a no-match, as it was before.
Let me know if I can proceed with this new feature integration 🙂 
Hi, thanks for getting back. Three remarks that I see on first glance:

1. The new argument of `jobs` should be named `pattern` as a regex pattern is what's expected. Moreover it could also accept `re.Pattern` objects in addition to strings.
2. The new argument *must* be optional, otherwise it would be breaking backward compatibility. If no pattern is passed, `jobs()` should just return all jubs as it's currently done.
3. To ensure that regex syntax within the `name` argument of `get_jobs_by_name` doesn't break the logic, you can use `re.escape`.

I suggest that you draft a PR with your edits so that we can do a proper review on the PR :) Please be sure to follow our [contribution guide](https://docs.python-telegram-bot.org/en/v21.6/contributing.html), especially the [checklist](https://docs.python-telegram-bot.org/en/v21.6/contributing.html#check-list-for-prs)
FYI, I went ahead and openend #4613

Commit message: Merge branch 'master' into regex-jobs

Patch to review:
diff --git a/telegram/ext/_jobqueue.py b/telegram/ext/_jobqueue.py
index 823d6c80cc1..a320004dbf4 100644
--- a/telegram/ext/_jobqueue.py
+++ b/telegram/ext/_jobqueue.py
@@ -19,6 +19,7 @@
 """This module contains the classes JobQueue and Job."""
 import asyncio
 import datetime as dtm
+import re
 import weakref
 from typing import TYPE_CHECKING, Any, Generic, Optional, Union, cast, overload
 
@@ -38,6 +39,8 @@
 from telegram.ext._utils.types import CCT, JobCallback
 
 if TYPE_CHECKING:
+    from collections.abc import Iterable
+
     if APS_AVAILABLE:
         from apscheduler.job import Job as APSJob
 
@@ -693,22 +696,43 @@ async def stop(self, wait: bool = True) -> None:
             # so give it a tiny bit of time to actually shut down.
             await asyncio.sleep(0.01)
 
-    def jobs(self) -> tuple["Job[CCT]", ...]:
+    def jobs(self, pattern: Union[str, re.Pattern[str], None] = None) -> tuple["Job[CCT]", ...]:
         """Returns a tuple of all *scheduled* jobs that are currently in the :class:`JobQueue`.
 
+        Args:
+            pattern (:obj:`str` | :obj:`re.Pattern`, optional): A regular expression pattern. If
+                passend, only jobs whose name matches the pattern will be returned.
+                Defaults to :obj:`None`.
+
+                Hint:
+                    This uses :func:`re.search` and not :func:`re.match`.
+
+                .. versionadded:: NEXT.VERSION
+
         Returns:
             tuple[:class:`Job`]: Tuple of all *scheduled* jobs.
         """
-        return tuple(Job.from_aps_job(job) for job in self.scheduler.get_jobs())
+        jobs_generator: Iterable[Job] = (
+            Job.from_aps_job(job) for job in self.scheduler.get_jobs()
+        )
+        if pattern is None:
+            return tuple(jobs_generator)
+        return tuple(
+            job for job in jobs_generator if (job.name and re.compile(pattern).search(job.name))
+        )
 
     def get_jobs_by_name(self, name: str) -> tuple["Job[CCT]", ...]:
-        """Returns a tuple of all *pending/scheduled* jobs with the given name that are currently
+        """Returns a tuple of all *scheduled* jobs with the given name that are currently
         in the :class:`JobQueue`.
 
+        Hint:
+            This method is a convenience wrapper for :meth:`jobs` with a pattern that matches the
+            given name.
+
         Returns:
-            tuple[:class:`Job`]: Tuple of all *pending* or *scheduled* jobs matching the name.
+            tuple[:class:`Job`]: Tuple of all *scheduled* jobs matching the name.
         """
-        return tuple(job for job in self.jobs() if job.name == name)
+        return self.jobs(f"^{re.escape(name)}$")
 
 
 class Job(Generic[CCT]):
diff --git a/tests/ext/test_jobqueue.py b/tests/ext/test_jobqueue.py
index 3de60cfbcfc..cd2886836a7 100644
--- a/tests/ext/test_jobqueue.py
+++ b/tests/ext/test_jobqueue.py
@@ -21,6 +21,7 @@
 import datetime as dtm
 import logging
 import platform
+import re
 import time
 
 import pytest
@@ -443,19 +444,34 @@ async def test_default_tzinfo(self, tz_bot):
 
         await jq.stop()
 
-    async def test_get_jobs(self, job_queue):
+    @pytest.mark.parametrize(
+        "pattern", [None, "not", re.compile("not")], ids=["None", "str", "re.Pattern"]
+    )
+    async def test_get_jobs(self, job_queue, pattern):
         callback = self.job_run_once
 
-        job1 = job_queue.run_once(callback, 10, name="name1")
+        job1 = job_queue.run_once(callback, 10, name="is|a|match")
         await asyncio.sleep(0.03)  # To stablize tests on windows
-        job2 = job_queue.run_once(callback, 10, name="name1")
+        job2 = job_queue.run_once(callback, 10, name="is|a|match")
         await asyncio.sleep(0.03)
-        job3 = job_queue.run_once(callback, 10, name="name2")
+        job3 = job_queue.run_once(callback, 10, name="not|is|a|match")
         await asyncio.sleep(0.03)
+        job4 = job_queue.run_once(callback, 10, name="is|a|match|not")
+        await asyncio.sleep(0.03)
+        job5 = job_queue.run_once(callback, 10, name="something_else")
+        await asyncio.sleep(0.03)
+        # name-less job
+        job6 = job_queue.run_once(callback, 10)
+        await asyncio.sleep(0.03)
+
+        if pattern is None:
+            assert job_queue.jobs(pattern) == (job1, job2, job3, job4, job5, job6)
+        else:
+            assert job_queue.jobs(pattern) == (job3, job4)
 
-        assert job_queue.jobs() == (job1, job2, job3)
-        assert job_queue.get_jobs_by_name("name1") == (job1, job2)
-        assert job_queue.get_jobs_by_name("name2") == (job3,)
+        assert job_queue.jobs() == (job1, job2, job3, job4, job5, job6)
+        assert job_queue.get_jobs_by_name("is|a|match") == (job1, job2)
+        assert job_queue.get_jobs_by_name("something_else") == (job5,)
 
     async def test_job_run(self, app):
         job = app.job_queue.run_repeating(self.job_run_once, 0.02)


Code review comments:
diff_hunk: @@ -693,22 +696,43 @@ async def stop(self, wait: bool = True) -> None:
             # so give it a tiny bit of time to actually shut down.
             await asyncio.sleep(0.01)
 
-    def jobs(self) -> tuple["Job[CCT]", ...]:
+    def jobs(self, pattern: Union[str, re.Pattern[str], None] = None) -> tuple["Job[CCT]", ...]:
         """Returns a tuple of all *scheduled* jobs that are currently in the :class:`JobQueue`.
 
+        Args:
+            pattern (:obj:`str` | :obj:`re.Pattern`, optional): A regular expression pattern. If
+                passend, only jobs whose name matches the pattern will be returned.
path: telegram/ext/_jobqueue.py
text: @user1:
```suggestion
                passed, only jobs whose name matches the pattern will be returned.
```