Repository: gorakhargosh/watchdog
Pull request #1002 (2023-08-10 13:52:46)

fixes a possible race condition in AutoRestartTrick
Just a long shot for a failure observed on #998. My hypothesis is that when we stop ProcessWatcher before we restart the process manually, we don't yield to it and immediately kill the process. Next, when the ProcessWatcher thread is woken up, we have to conditions ready - the popen_obj and stopped_event, see the corresponding code, 

```
 while True: if self.popen_obj.poll() is not None: 
     break 
  if self.stopped_event.wait(timeout=0.1): 
     return 

```

And despite that `stopped_event` is set, we first check for `popen_obj` and trigger the process restart.

We can <strike>also</strike> make the ProcessWatcher logic more robust, by checking if we are stopped before calling the termination callback, e.g.,

```
        try:
            if not self.stopped_event.is_set():
                self.process_termination_callback()
        except Exception:
            logger.exception("Error calling process termination callback")
```

I am not 100% sure about that, as I don't really know what semantics is expected from ProcessWatcher by other users. But at least the AutoRestarter expects this semantics - i.e., a watcher shall not call any events after it was stopped.

ivg: Okay, the first option didn't work, so I tried the second and the tests passed, at least on Linux, not sure what's going on with macOS, but it looks like it is a known issue, correct me if I am wrong. 

Patch:
diff --git a/.cirrus.yml b/.cirrus.yml
index 488ce79..b305a1a 100644
--- a/.cirrus.yml
+++ b/.cirrus.yml
@@ -16,6 +16,6 @@ task:
     - python3.8 -m pip install -U pip
     - python3.8 -m pip install -r requirements-tests.txt
   lint_script:
-    - python3.8 -m flake8 docs src tests tools
+    - python3.8 -m ruff src
   tests_script:
     - python3.8 -bb -m pytest tests
diff --git a/.github/dependabot.yml b/.github/dependabot.yml
new file mode 100644
index 0000000..c78a085
--- /dev/null
+++ b/.github/dependabot.yml
@@ -0,0 +1,7 @@
+version: 2
+updates:
+  # GitHub Actions
+  - package-ecosystem: github-actions
+    directory: /
+    schedule:
+      interval: daily
diff --git a/.github/workflows/build-and-publish.yml b/.github/workflows/build-and-publish.yml
index 3877d25..de4371f 100644
--- a/.github/workflows/build-and-publish.yml
+++ b/.github/workflows/build-and-publish.yml
@@ -21,7 +21,7 @@ on:
       - master
   pull_request:
     branches:
-      - '**'
+      - "**"
 
   workflow_dispatch:
     inputs:
@@ -41,11 +41,11 @@ jobs:
     timeout-minutes: 20
     steps:
       - name: Checkout
-        uses: actions/checkout@v3
+        uses: actions/checkout@v4
         with:
           ref: ${{ github.event.inputs.branch }}
       - name: Install Python
-        uses: actions/setup-python@v4
+        uses: actions/setup-python@v5
         with:
           python-version: "3.11"
       - name: Install build dependencies
@@ -55,9 +55,11 @@ jobs:
         env:
           CIBW_SKIP: "cp36-*" # skip 3.6 wheels
           CIBW_ARCHS_MACOS: "x86_64 universal2 arm64"
-      - uses: actions/upload-artifact@v3
+      - name: Artifacts list
+        run: ls -l wheelhouse
+      - uses: actions/upload-artifact@v4
         with:
-          name: python-package-distributions
+          name: python-package-distributions-macos
           path: ./wheelhouse/*.whl
 
   pure-built-distributions:
@@ -66,11 +68,11 @@ jobs:
     timeout-minutes: 5
     steps:
       - name: Checkout
-        uses: actions/checkout@v3
+        uses: actions/checkout@v4
         with:
           ref: ${{ github.event.inputs.branch }}
       - name: Install Python
-        uses: actions/setup-python@v4
+        uses: actions/setup-python@v5
         with:
           python-version: "3.11"
       - name: Install build dependencies
@@ -81,9 +83,11 @@ jobs:
           do
             python setup.py bdist_wheel --plat-name $platform
           done
-      - uses: actions/upload-artifact@v3
+      - name: Artifacts list
+        run: ls -l dist
+      - uses: actions/upload-artifact@v4
         with:
-          name: python-package-distributions
+          name: python-package-distributions-pure-wheels
           path: ./dist/*.whl
 
   source-distribution:
@@ -92,21 +96,22 @@ jobs:
     timeout-minutes: 5
     steps:
       - name: Checkout
-        uses: actions/checkout@v3
+        uses: actions/checkout@v4
         with:
           ref: ${{ github.event.inputs.branch }}
       - name: Install Python
-        uses: actions/setup-python@v4
+        uses: actions/setup-python@v5
         with:
           python-version: "3.11"
       - name: Build source distribution
         run: python setup.py sdist
+      - name: Artifacts list
+        run: ls -l dist
       - name: Store the source distribution
-        uses: actions/upload-artifact@v3
+        uses: actions/upload-artifact@v4
         with:
-          name: python-package-distributions
-          path: dist
-          retention-days: 4
+          name: python-package-distributions-source
+          path: dist/*.tar.gz
 
   publish:
     needs:
@@ -117,16 +122,17 @@ jobs:
     timeout-minutes: 5
     steps:
       - name: Download all the dists
-        uses: actions/download-artifact@v2
+        uses: actions/download-artifact@v4
         with:
-          name: python-package-distributions
+          pattern: python-package-distributions-*
+          merge-multiple: true
           path: dist/
       - name: What will we publish?
         run: ls -l dist
       - name: Publish
         if: github.event.inputs.branch != ''
-        uses: pypa/gh-action-pypi-publish@master
+        uses: pypa/gh-action-pypi-publish@release/v1
         with:
           user: __token__
           password: ${{ secrets.PYPI_API_TOKEN }}
-          skip_existing: true
+          skip-existing: true
diff --git a/.isort.cfg b/.isort.cfg
deleted file mode 100644
index 7557202..0000000
--- a/.isort.cfg
+++ /dev/null
@@ -1,5 +0,0 @@
-[settings]
-line_length = 120
-profile=black
-skip_gitignore=true
-add_imports=from __future__ import annotations
diff --git a/README.rst b/README.rst
index 5db0898..2d6e0dd 100755
--- a/README.rst
+++ b/README.rst
@@ -6,7 +6,7 @@ Watchdog
 
 Python API and shell utilities to monitor file system events.
 
-Works on 3.7+.
+Works on 3.8+.
 
 Example API Usage
 -----------------
@@ -16,28 +16,26 @@ as command-line arguments and logs events generated:
 
 .. code-block:: python
 
-    import sys
     import time
-    import logging
     from watchdog.observers import Observer
-    from watchdog.events import LoggingEventHandler
-
-    if __name__ == "__main__":
-        logging.basicConfig(level=logging.INFO,
-                            format='%(asctime)s - %(message)s',
-                            datefmt='%Y-%m-%d %H:%M:%S')
-        path = sys.argv[1] if len(sys.argv) > 1 else '.'
-        logging.info(f'start watching directory {path!r}')
-        event_handler = LoggingEventHandler()
-        observer = Observer()
-        observer.schedule(event_handler, path, recursive=True)
-        observer.start()
-        try:
-            while True:
-                time.sleep(1)
-        finally:
-            observer.stop()
-            observer.join()
+    from watchdog.events import FileSystemEventHandler
+
+
+    class MyEventHandler(FileSystemEventHandler):
+        def on_any_event(self, event):
+            print(event)
+
+
+    event_handler = MyEventHandler()
+    observer = Observer()
+    observer.schedule(event_handler, '.', recursive=True)
+    observer.start()
+    try:
+        while True:
+            time.sleep(1)
+    finally:
+        observer.stop()
+        observer.join()
 
 
 Shell Utilities
@@ -213,7 +211,7 @@ appropriate observer like in the example above, do::
 Dependencies
 ------------
 
-1. Python 3.7 or above.
+1. Python 3.8 or above.
 2. XCode_ (only on macOS when installing from sources)
 3. PyYAML_ (only for ``watchmedo``)
 
diff --git a/changelog.rst b/changelog.rst
index 5a2d944..ff507fe 100644
--- a/changelog.rst
+++ b/changelog.rst
@@ -3,18 +3,39 @@
 Changelog
 ---------
 
-3.0.1
+4.0.2 (dev)
+~~~~~~~~~~~
+
+2024-xx-xx • `full history <https://github.com/gorakhargosh/watchdog/compare/v4.0.1...HEAD>`__
+
+- [core] Run ``ruff``, apply several fixes (`#1033 <https://github.com/gorakhargosh/watchdog/pull/1033>`__)
+- [fsevents] Add missing ``event_filter`` keyword-argument to ``FSEventsObserver.schedule()`` (`#1049 <https://github.com/gorakhargosh/watchdog/pull/1049>`__)
+- Thanks to our beloved contributors: @BoboTiG
+
+4.0.1
 ~~~~~
 
-2023-xx-xx • `full history <https://github.com/gorakhargosh/watchdog/compare/v3.0.0...HEAD>`__
+2024-05-23 • `full history <https://github.com/gorakhargosh/watchdog/compare/v4.0.0...v4.0.1>`__
+
+- [inotify] Fix missing ``event_filter`` for the full emitter (`#1032 <https://github.com/gorakhargosh/watchdog/pull/1032>`__)
+- Thanks to our beloved contributors: @mraspaud, @BoboTiG
 
+4.0.0
+~~~~~
+
+2024-02-06 • `full history <https://github.com/gorakhargosh/watchdog/compare/v3.0.0...v4.0.0>`__
+
+- Drop support for Python 3.7.
+- Add support for Python 3.12.
+- [snapshot] Add typing to ``dirsnapshot`` (`#1012 <https://github.com/gorakhargosh/watchdog/pull/1012>`__)
+- [snapshot] Added ``DirectorySnapshotDiff.ContextManager`` (`#1011 <https://github.com/gorakhargosh/watchdog/pull/1011>`__)
 - [events] ``FileSystemEvent``, and subclasses, are now ``dataclass``es, and their ``repr()`` has changed
 - [windows] ``WinAPINativeEvent`` is now a ``dataclass``, and its ``repr()`` has changed
 - [events] Log ``FileOpenedEvent``, and ``FileClosedEvent``, events in ``LoggingEventHandler``
 - [tests] Improve ``FileSystemEvent`` coverage
 - [watchmedo] Log all events in ``LoggerTrick``
 - [windows] The ``observers.read_directory_changes.WATCHDOG_TRAVERSE_MOVED_DIR_DELAY`` hack was removed. The constant will be kept to prevent breaking other softwares.
-- Thanks to our beloved contributors: @BoboTiG
+- Thanks to our beloved contributors: @BoboTiG, @msabramo
 
 3.0.0
 ~~~~~
diff --git a/docs/source/conf.py b/docs/source/conf.py
index 49163fa..86e5424 100644
--- a/docs/source/conf.py
+++ b/docs/source/conf.py
@@ -15,15 +15,15 @@
 # If extensions (or modules to document with autodoc) are in another directory,
 # add these directories to sys.path here. If the directory is relative to the
 # documentation root, use os.path.abspath to make it absolute, like shown here.
-TOP_DIR_PATH = os.path.abspath("../../")  # noqa
-SRC_DIR_PATH = os.path.join(TOP_DIR_PATH, "src")  # noqa
-sys.path.insert(0, SRC_DIR_PATH)  # noqa
+TOP_DIR_PATH = os.path.abspath("../../")
+SRC_DIR_PATH = os.path.join(TOP_DIR_PATH, "src")
+sys.path.insert(0, SRC_DIR_PATH)
 
-import watchdog.version  # noqa
+import watchdog.version  # noqa: E402
 
 PROJECT_NAME = "watchdog"
 AUTHOR_NAME = "Yesudeep Mangalapilly and contributors"
-COPYRIGHT = "2010-2023, " + AUTHOR_NAME
+COPYRIGHT = f"2010-2024, {AUTHOR_NAME}"
 
 
 # -- General configuration -----------------------------------------------------
diff --git a/docs/source/global.rst.inc b/docs/source/global.rst.inc
index 21f02d1..59d46c6 100644
--- a/docs/source/global.rst.inc
+++ b/docs/source/global.rst.inc
@@ -2,9 +2,9 @@
 
 .. |author_name| replace:: Yesudeep Mangalapilly
 .. |author_email| replace:: yesudeep@gmail.com
-.. |copyright| replace:: Copyright 2012-2023 Google, Inc & contributors.
+.. |copyright| replace:: Copyright 2012-2024 Google, Inc & contributors.
 .. |project_name| replace:: ``watchdog``
-.. |project_version| replace:: 3.0.1
+.. |project_version| replace:: 4.0.2
 
 .. _issue tracker: https://github.com/gorakhargosh/watchdog/issues
 .. _code repository: https://github.com/gorakhargosh/watchdog
diff --git a/docs/source/index.rst b/docs/source/index.rst
index ce60b69..8365309 100644
--- a/docs/source/index.rst
+++ b/docs/source/index.rst
@@ -11,7 +11,7 @@ Watchdog
 
 Python API library and shell utilities to monitor file system events.
 
-Works on 3.7+.
+Works on 3.8+.
 
 Directory monitoring made easy with
 -----------------------------------
diff --git a/docs/source/installation.rst b/docs/source/installation.rst
index f7fea16..e1e331d 100644
--- a/docs/source/installation.rst
+++ b/docs/source/installation.rst
@@ -4,7 +4,7 @@
 
 Installation
 ============
-|project_name| requires 3.7+ to work. See a list of :ref:`installation-dependencies`.
+|project_name| requires 3.8+ to work. See a list of :ref:`installation-dependencies`.
 
 Installing from PyPI using pip
 ------------------------------
diff --git a/docs/source/quickstart.rst b/docs/source/quickstart.rst
index 9b73500..d251f3b 100644
--- a/docs/source/quickstart.rst
+++ b/docs/source/quickstart.rst
@@ -10,9 +10,7 @@ to detect changes. Here is what we will do with the API:
 
 1. Create an instance of the :class:`watchdog.observers.Observer` thread class.
 
-2. Implement a subclass of :class:`watchdog.events.FileSystemEventHandler`
-   (or as in our case, we will use the built-in
-   :class:`watchdog.events.LoggingEventHandler`, which already does).
+2. Implement a subclass of :class:`watchdog.events.FileSystemEventHandler`.
 
 3. Schedule monitoring a few paths with the observer instance
    attaching the event handler.
@@ -29,27 +27,27 @@ entire directory trees is ensured.
 A Simple Example
 ----------------
 The following example program will monitor the current directory recursively for
-file system changes and simply log them to the console::
+file system changes and simply print them to the console::
 
-    import sys
-    import logging
+    import time
     from watchdog.observers import Observer
-    from watchdog.events import LoggingEventHandler
-
-    if __name__ == "__main__":
-        logging.basicConfig(level=logging.INFO,
-                            format='%(asctime)s - %(message)s',
-                            datefmt='%Y-%m-%d %H:%M:%S')
-        path = sys.argv[1] if len(sys.argv) > 1 else '.'
-        event_handler = LoggingEventHandler()
-        observer = Observer()
-        observer.schedule(event_handler, path, recursive=True)
-        observer.start()
-        try:
-            while observer.is_alive():
-                observer.join(1)
-        finally:
-            observer.stop()
-            observer.join()
+    from watchdog.events import FileSystemEventHandler
+
+
+    class MyEventHandler(FileSystemEventHandler):
+        def on_any_event(self, event):
+            print(event)
+
+
+    event_handler = MyEventHandler()
+    observer = Observer()
+    observer.schedule(event_handler, '.', recursive=True)
+    observer.start()
+    try:
+        while True:
+            time.sleep(1)
+    finally:
+        observer.stop()
+        observer.join()
 
 To stop the program, press Control-C.
diff --git a/mypy.ini b/mypy.ini
deleted file mode 100644
index d8796ad..0000000
--- a/mypy.ini
+++ /dev/null
@@ -1,17 +0,0 @@
-[mypy]
-files=tools,src,tests
-show_error_codes = True
-warn_unused_ignores = True
-
-;disallow_any_generics = True
-disallow_subclassing_any = True
-;disallow_untyped_calls = True
-;disallow_untyped_defs = True
-disallow_incomplete_defs = True
-check_untyped_defs = True
-disallow_untyped_decorators = True
-no_implicit_optional = True
-warn_return_any = True
-no_implicit_reexport = True
-strict_equality = True
-warn_redundant_casts = True
diff --git a/pyproject.toml b/pyproject.toml
new file mode 100644
index 0000000..9be9550
--- /dev/null
+++ b/pyproject.toml
@@ -0,0 +1,72 @@
+[tool.mypy]
+# Ensure we know what we do
+warn_redundant_casts = true
+warn_unused_ignores = true
+warn_unused_configs = true
+
+# Imports management
+ignore_missing_imports = true
+follow_imports = "skip"
+
+# Ensure full coverage
+#disallow_untyped_defs = true [TODO]
+disallow_incomplete_defs = true
+#disallow_untyped_calls = true [TODO]
+
+# Restrict dynamic typing (a little)
+# e.g. `x: List[Any]` or x: List`
+# disallow_any_generics = true
+
+strict_equality = true
+
+[tool.pytest.ini_options]
+pythonpath = "src"
+addopts = """
+    --showlocals
+    -vvv
+    --cov=watchdog
+    --cov-report=term-missing:skip-covered
+"""
+
+[tool.ruff]
+line-length = 120
+indent-width = 4
+target-version = "py38"
+
+[tool.ruff.lint]
+extend-select = ["ALL"]
+ignore = [
+    "ARG",
+    "ANN",  # TODO
+    "ANN002",
+    "ANN003",
+    "ANN401",
+    "B006",
+    "B023",  # TODO
+    "B028",
+    "BLE001",
+    "C90",
+    "COM",
+    "D",
+    "EM",
+    "ERA",
+    "FBT",
+    "FIX",
+    "ISC001",
+    "N",  # Requires a major version number bump
+    "PERF203",  # TODO
+    "PL",
+    "PTH",  # TODO?
+    "S",
+    "TD",
+    "TRY003",
+    "UP",  # TODO when minimum python version will be 3.10
+]
+fixable = ["ALL"]
+
+[tool.ruff.format]
+quote-style = "double"
+indent-style = "space"
+skip-magic-trailing-comma = false
+line-ending = "auto"
+docstring-code-format = true
diff --git a/setup.cfg b/setup.cfg
index 8c5532b..61c7f61 100644
--- a/setup.cfg
+++ b/setup.cfg
@@ -10,21 +10,6 @@ source-dir = docs/source
 build-dir  = docs/build
 all_files  = 1
 
-[flake8]
-ignore =
-    # E203 whitespace before ':', but E203 is not PEP 8 compliant
-    E203
-    # W503 line break before binary operator, but W503 is not PEP 8 compliant
-    W503
-max-line-length = 120
-
 [upload_sphinx]
 # Requires sphinx-pypi-upload to work.
 upload-dir = docs/build/html
-
-[tool:pytest]
-addopts =
-    --showlocals
-    -v
-    --cov=watchdog
-    --cov-report=term-missing
diff --git a/setup.py b/setup.py
index 337e4be..320d951 100644
--- a/setup.py
+++ b/setup.py
@@ -129,11 +129,11 @@
         "Programming Language :: Python",
         "Programming Language :: Python :: 3",
         "Programming Language :: Python :: 3 :: Only",
-        "Programming Language :: Python :: 3.7",
         "Programming Language :: Python :: 3.8",
         "Programming Language :: Python :: 3.9",
         "Programming Language :: Python :: 3.10",
         "Programming Language :: Python :: 3.11",
+        "Programming Language :: Python :: 3.12",
         "Programming Language :: Python :: Implementation :: PyPy",
         "Programming Language :: C",
         "Topic :: Software Development :: Libraries",
@@ -154,6 +154,6 @@
             "watchmedo = watchdog.watchmedo:main [watchmedo]",
         ]
     },
-    python_requires=">=3.7",
+    python_requires=">=3.8",
     zip_safe=False,
 )
diff --git a/src/watchdog/events.py b/src/watchdog/events.py
old mode 100755
new mode 100644
index 51c6c5b..8b8d634
--- a/src/watchdog/events.py
+++ b/src/watchdog/events.py
@@ -13,8 +13,7 @@
 # See the License for the specific language governing permissions and
 # limitations under the License.
 
-"""
-:module: watchdog.events
+""":module: watchdog.events
 :synopsis: File system events and event handlers.
 :author: yesudeep@google.com (Yesudeep Mangalapilly)
 :author: contact@tiger-222.fr (Mickaël Schoentgen)
@@ -111,8 +110,7 @@
 
 @dataclass(unsafe_hash=True)
 class FileSystemEvent:
-    """
-    Immutable type that represents a file system event that is triggered
+    """Immutable type that represents a file system event that is triggered
     when a change occurs on the monitored file system.
 
     All FileSystemEvent objects are required to be immutable and hence
@@ -186,9 +184,7 @@ class DirDeletedEvent(FileSystemEvent):
 
 
 class DirModifiedEvent(FileSystemEvent):
-    """
-    File system event representing directory modification on the file system.
-    """
+    """File system event representing directory modification on the file system."""
 
     event_type = EVENT_TYPE_MODIFIED
     is_directory = True
@@ -208,9 +204,7 @@ class DirMovedEvent(FileSystemMovedEvent):
 
 
 class FileSystemEventHandler:
-    """
-    Base file system event handler that you can override methods from.
-    """
+    """Base file system event handler that you can override methods from."""
 
     def dispatch(self, event: FileSystemEvent) -> None:
         """Dispatches events to the appropriate methods.
@@ -297,6 +291,8 @@ def on_opened(self, event: FileSystemEvent) -> None:
 class PatternMatchingEventHandler(FileSystemEventHandler):
     """
     Matches given patterns with file paths associated with occurring events.
+    Uses pathlib's `PurePath.match()` method. `patterns` and `ignore_patterns`
+    are expected to be a list of strings.
     """
 
     def __init__(
@@ -315,32 +311,28 @@ def __init__(
 
     @property
     def patterns(self):
-        """
-        (Read-only)
+        """(Read-only)
         Patterns to allow matching event paths.
         """
         return self._patterns
 
     @property
     def ignore_patterns(self):
-        """
-        (Read-only)
+        """(Read-only)
         Patterns to ignore matching event paths.
         """
         return self._ignore_patterns
 
     @property
     def ignore_directories(self):
-        """
-        (Read-only)
+        """(Read-only)
         ``True`` if directories should be ignored; ``False`` otherwise.
         """
         return self._ignore_directories
 
     @property
     def case_sensitive(self):
-        """
-        (Read-only)
+        """(Read-only)
         ``True`` if path names should be matched sensitive to case; ``False``
         otherwise.
         """
@@ -375,6 +367,7 @@ def dispatch(self, event: FileSystemEvent) -> None:
 class RegexMatchingEventHandler(FileSystemEventHandler):
     """
     Matches given regexes with file paths associated with occurring events.
+    Uses the `re` module.
     """
 
     def __init__(
@@ -396,39 +389,35 @@ def __init__(
             self._regexes = [re.compile(r) for r in regexes]
             self._ignore_regexes = [re.compile(r) for r in ignore_regexes]
         else:
-            self._regexes = [re.compile(r, re.I) for r in regexes]
-            self._ignore_regexes = [re.compile(r, re.I) for r in ignore_regexes]
+            self._regexes = [re.compile(r, re.IGNORECASE) for r in regexes]
+            self._ignore_regexes = [re.compile(r, re.IGNORECASE) for r in ignore_regexes]
         self._ignore_directories = ignore_directories
         self._case_sensitive = case_sensitive
 
     @property
     def regexes(self):
-        """
-        (Read-only)
+        """(Read-only)
         Regexes to allow matching event paths.
         """
         return self._regexes
 
     @property
     def ignore_regexes(self):
-        """
-        (Read-only)
+        """(Read-only)
         Regexes to ignore matching event paths.
         """
         return self._ignore_regexes
 
     @property
     def ignore_directories(self):
-        """
-        (Read-only)
+        """(Read-only)
         ``True`` if directories should be ignored; ``False`` otherwise.
         """
         return self._ignore_directories
 
     @property
     def case_sensitive(self):
-        """
-        (Read-only)
+        """(Read-only)
         ``True`` if path names should be matched sensitive to case; ``False``
         otherwise.
         """
diff --git a/src/watchdog/observers/__init__.py b/src/watchdog/observers/__init__.py
index ab92c61..293a6f6 100644
--- a/src/watchdog/observers/__init__.py
+++ b/src/watchdog/observers/__init__.py
@@ -13,8 +13,7 @@
 # See the License for the specific language governing permissions and
 # limitations under the License.
 
-"""
-:module: watchdog.observers
+""":module: watchdog.observers
 :synopsis: Observer that picks a native implementation if available.
 :author: yesudeep@google.com (Yesudeep Mangalapilly)
 :author: contact@tiger-222.fr (Mickaël Schoentgen)
@@ -52,47 +51,52 @@
 
 from __future__ import annotations
 
-import sys
+import contextlib
 import warnings
+from typing import TYPE_CHECKING
 
-from watchdog.utils import UnsupportedLibc
-
-from .api import BaseObserverSubclassCallable
+from watchdog.utils import UnsupportedLibc, platform
 
-Observer: BaseObserverSubclassCallable
+if TYPE_CHECKING:
+    from watchdog.observers.api import BaseObserverSubclassCallable
 
 
-if sys.platform.startswith("linux"):
-    try:
-        from .inotify import InotifyObserver as Observer
-    except UnsupportedLibc:
-        from .polling import PollingObserver as Observer
+def _get_observer_cls() -> BaseObserverSubclassCallable:
+    if platform.is_linux():
+        with contextlib.suppress(UnsupportedLibc):
+            from watchdog.observers.inotify import InotifyObserver
 
-elif sys.platform.startswith("darwin"):
-    try:
-        from .fsevents import FSEventsObserver as Observer
-    except Exception:
+            return InotifyObserver
+    elif platform.is_darwin():
         try:
-            from .kqueue import KqueueObserver as Observer
-
-            warnings.warn("Failed to import fsevents. Fall back to kqueue")
+            from watchdog.observers.fsevents import FSEventsObserver
+        except Exception:
+            try:
+                from watchdog.observers.kqueue import KqueueObserver
+            except Exception:
+                warnings.warn("Failed to import fsevents and kqueue. Fall back to polling.")
+            else:
+                warnings.warn("Failed to import fsevents. Fall back to kqueue")
+                return KqueueObserver
+        else:
+            return FSEventsObserver
+    elif platform.is_windows():
+        try:
+            from watchdog.observers.read_directory_changes import WindowsApiObserver
         except Exception:
-            from .polling import PollingObserver as Observer
+            warnings.warn("Failed to import `read_directory_changes`. Fall back to polling.")
+        else:
+            return WindowsApiObserver
+    elif platform.is_bsd():
+        from watchdog.observers.kqueue import KqueueObserver
 
-            warnings.warn("Failed to import fsevents and kqueue. Fall back to polling.")
+        return KqueueObserver
 
-elif sys.platform in ("dragonfly", "freebsd", "netbsd", "openbsd", "bsd"):
-    from .kqueue import KqueueObserver as Observer
+    from watchdog.observers.polling import PollingObserver
 
-elif sys.platform.startswith("win"):
-    try:
-        from .read_directory_changes import WindowsApiObserver as Observer
-    except Exception:
-        from .polling import PollingObserver as Observer
+    return PollingObserver
 
-        warnings.warn("Failed to import read_directory_changes. Fall back to polling.")
 
-else:
-    from .polling import PollingObserver as Observer
+Observer = _get_observer_cls()
 
 __all__ = ["Observer"]
diff --git a/src/watchdog/observers/api.py b/src/watchdog/observers/api.py
index 50550f3..4dc7d3c 100644
--- a/src/watchdog/observers/api.py
+++ b/src/watchdog/observers/api.py
@@ -15,6 +15,7 @@
 
 from __future__ import annotations
 
+import contextlib
 import queue
 import threading
 from pathlib import Path
@@ -26,7 +27,6 @@
 DEFAULT_OBSERVER_TIMEOUT = 1  # in seconds.
 
 
-# Collection classes
 class EventQueue(SkipRepeatsQueue):
     """Thread-safe event queue based on a special queue that skips adding
     the same event (:class:`FileSystemEvent`) multiple times consecutively.
@@ -48,10 +48,7 @@ class ObservedWatch:
     """
 
     def __init__(self, path, recursive, event_filter=None):
-        if isinstance(path, Path):
-            self._path = str(path)
-        else:
-            self._path = path
+        self._path = str(path) if isinstance(path, Path) else path
         self._is_recursive = recursive
         self._event_filter = frozenset(event_filter) if event_filter is not None else None
 
@@ -94,8 +91,7 @@ def __repr__(self):
 
 # Observer classes
 class EventEmitter(BaseThread):
-    """
-    Producer thread base class subclassed by event emitters
+    """Producer thread base class subclassed by event emitters
     that generate events and populate a queue with them.
 
     :param event_queue:
@@ -125,21 +121,16 @@ def __init__(self, event_queue, watch, timeout=DEFAULT_EMITTER_TIMEOUT, event_fi
 
     @property
     def timeout(self):
-        """
-        Blocking timeout for reading events.
-        """
+        """Blocking timeout for reading events."""
         return self._timeout
 
     @property
     def watch(self):
-        """
-        The watch associated with this emitter.
-        """
+        """The watch associated with this emitter."""
         return self._watch
 
     def queue_event(self, event):
-        """
-        Queues a single event.
+        """Queues a single event.
 
         :param event:
             Event to be queued.
@@ -167,8 +158,7 @@ def run(self):
 
 
 class EventDispatcher(BaseThread):
-    """
-    Consumer thread base class subclassed by event observer threads
+    """Consumer thread base class subclassed by event observer threads
     that dispatch events from an event queue to appropriate event handlers.
 
     :param timeout:
@@ -178,7 +168,7 @@ class EventDispatcher(BaseThread):
         ``float``
     """
 
-    _stop_event = object()
+    stop_event = object()
     """Event inserted into the queue to signal a requested stop."""
 
     def __init__(self, timeout=DEFAULT_OBSERVER_TIMEOUT):
@@ -193,16 +183,15 @@ def timeout(self):
 
     def stop(self):
         BaseThread.stop(self)
-        try:
-            self.event_queue.put_nowait(EventDispatcher._stop_event)
-        except queue.Full:
-            pass
+        with contextlib.suppress(queue.Full):
+            self.event_queue.put_nowait(EventDispatcher.stop_event)
 
     @property
     def event_queue(self):
         """The event queue which is populated with file system events
         by emitters and from which events are dispatched by a dispatcher
-        thread."""
+        thread.
+        """
         return self._event_queue
 
     def dispatch_events(self, event_queue):
@@ -233,9 +222,9 @@ def __init__(self, emitter_class, timeout=DEFAULT_OBSERVER_TIMEOUT):
         self._emitter_class = emitter_class
         self._lock = threading.RLock()
         self._watches = set()
-        self._handlers = dict()
+        self._handlers = {}
         self._emitters = set()
-        self._emitter_for_watch = dict()
+        self._emitter_for_watch = {}
 
     def _add_emitter(self, emitter):
         self._emitter_for_watch[emitter.watch] = emitter
@@ -245,19 +234,15 @@ def _remove_emitter(self, emitter):
         del self._emitter_for_watch[emitter.watch]
         self._emitters.remove(emitter)
         emitter.stop()
-        try:
+        with contextlib.suppress(RuntimeError):
             emitter.join()
-        except RuntimeError:
-            pass
 
     def _clear_emitters(self):
         for emitter in self._emitters:
             emitter.stop()
         for emitter in self._emitters:
-            try:
+            with contextlib.suppress(RuntimeError):
                 emitter.join()
-            except RuntimeError:
-                pass
         self._emitters.clear()
         self._emitter_for_watch.clear()
 
@@ -284,8 +269,7 @@ def start(self):
         super().start()
 
     def schedule(self, event_handler, path, recursive=False, event_filter=None):
-        """
-        Schedules watching a path and calls appropriate methods specified
+        """Schedules watching a path and calls appropriate methods specified
         in the given event handler in response to file system events.
 
         :param event_handler:
@@ -317,8 +301,7 @@ def schedule(self, event_handler, path, recursive=False, event_filter=None):
 
             # If we don't have an emitter for this watch already, create it.
             if self._emitter_for_watch.get(watch) is None:
-                emitter = self._emitter_class(event_queue=self.event_queue, watch=watch, timeout=self.timeout,
-                                              event_filter=event_filter)
+                emitter = self._emitter_class(self.event_queue, watch, timeout=self.timeout, event_filter=event_filter)
                 if self.is_alive():
                     emitter.start()
                 self._add_emitter(emitter)
@@ -378,7 +361,8 @@ def unschedule(self, watch):
 
     def unschedule_all(self):
         """Unschedules all watches and detaches all associated event
-        handlers."""
+        handlers.
+        """
         with self._lock:
             self._handlers.clear()
             self._clear_emitters()
@@ -389,7 +373,7 @@ def on_thread_stop(self):
 
     def dispatch_events(self, event_queue):
         entry = event_queue.get(block=True)
-        if entry is EventDispatcher._stop_event:
+        if entry is EventDispatcher.stop_event:
             return
         event, watch = entry
 
@@ -404,5 +388,4 @@ def dispatch_events(self, event_queue):
 
 
 class BaseObserverSubclassCallable(Protocol):
-    def __call__(self, timeout: float = ...) -> BaseObserver:
-        ...
+    def __call__(self, timeout: float = ...) -> BaseObserver: ...
diff --git a/src/watchdog/observers/fsevents.py b/src/watchdog/observers/fsevents.py
index 39831e6..c3b29cd 100644
--- a/src/watchdog/observers/fsevents.py
+++ b/src/watchdog/observers/fsevents.py
@@ -13,8 +13,7 @@
 # See the License for the specific language governing permissions and
 # limitations under the License.
 
-"""
-:module: watchdog.observers.fsevents
+""":module: watchdog.observers.fsevents
 :synopsis: FSEvents based emitter implementation.
 :author: yesudeep@google.com (Yesudeep Mangalapilly)
 :author: contact@tiger-222.fr (Mickaël Schoentgen)
@@ -29,7 +28,7 @@
 import time
 import unicodedata
 
-import _watchdog_fsevents as _fsevents  # type: ignore[import]
+import _watchdog_fsevents as _fsevents
 
 from watchdog.events import (
     DirCreatedEvent,
@@ -50,9 +49,7 @@
 
 
 class FSEventsEmitter(EventEmitter):
-
-    """
-    macOS FSEvents Emitter class.
+    """macOS FSEvents Emitter class.
 
     :param event_queue:
         The event queue to fill with events.
@@ -97,15 +94,11 @@ def on_thread_stop(self):
     def queue_event(self, event):
         # fsevents defaults to be recursive, so if the watch was meant to be non-recursive then we need to drop
         # all the events here which do not have a src_path / dest_path that matches the watched path
-        if self._watch.is_recursive:
+        if self._watch.is_recursive or not self._is_recursive_event(event):
             logger.debug("queue_event %s", event)
             EventEmitter.queue_event(self, event)
         else:
-            if not self._is_recursive_event(event):
-                logger.debug("queue_event %s", event)
-                EventEmitter.queue_event(self, event)
-            else:
-                logger.debug("drop event %s", event)
+            logger.debug("drop event %s", event)
 
     def _is_recursive_event(self, event):
         src_path = event.src_path if event.is_directory else os.path.dirname(event.src_path)
@@ -168,7 +161,7 @@ def queue_events(self, timeout, events):
         if logger.getEffectiveLevel() <= logging.DEBUG:
             for event in events:
                 flags = ", ".join(attr for attr in dir(event) if getattr(event, attr) is True)
-                logger.debug(f"{event}: {flags}")
+                logger.debug("%s: %s", event, flags)
 
         if time.monotonic() - self._start_time > 60:
             # Event history is no longer needed, let's free some memory.
@@ -319,27 +312,22 @@ def run(self):
 
     def on_thread_start(self):
         if self.suppress_history:
-            if isinstance(self.watch.path, bytes):
-                watch_path = os.fsdecode(self.watch.path)
-            else:
-                watch_path = self.watch.path
-
+            watch_path = os.fsdecode(self.watch.path) if isinstance(self.watch.path, bytes) else self.watch.path
             self._starting_state = DirectorySnapshot(watch_path)
 
     def _encode_path(self, path):
         """Encode path only if bytes were passed to this emitter."""
-        if isinstance(self.watch.path, bytes):
-            return os.fsencode(path)
-        return path
+        return os.fsencode(path) if isinstance(self.watch.path, bytes) else path
 
 
 class FSEventsObserver(BaseObserver):
     def __init__(self, timeout=DEFAULT_OBSERVER_TIMEOUT):
-        super().__init__(emitter_class=FSEventsEmitter, timeout=timeout)
+        super().__init__(FSEventsEmitter, timeout=timeout)
 
-    def schedule(self, event_handler, path, recursive=False):
+    def schedule(self, event_handler, path, recursive=False, event_filter=None):
         # Fix for issue #26: Trace/BPT error when given a unicode path
         # string. https://github.com/gorakhargosh/watchdog/issues#issue/26
         if isinstance(path, str):
             path = unicodedata.normalize("NFC", path)
-        return BaseObserver.schedule(self, event_handler, path, recursive)
+
+        return super().schedule(event_handler, path, recursive=recursive, event_filter=event_filter)
diff --git a/src/watchdog/observers/fsevents2.py b/src/watchdog/observers/fsevents2.py
index de3d109..7194174 100644
--- a/src/watchdog/observers/fsevents2.py
+++ b/src/watchdog/observers/fsevents2.py
@@ -12,8 +12,7 @@
 # See the License for the specific language governing permissions and
 # limitations under the License.
 
-"""
-:module: watchdog.observers.fsevents2
+""":module: watchdog.observers.fsevents2
 :synopsis: FSEvents based emitter implementation.
 :platforms: macOS
 """
@@ -29,8 +28,8 @@
 from typing import List, Optional, Type
 
 # pyobjc
-import AppKit  # type: ignore[import]
-from FSEvents import (  # type: ignore[import]
+import AppKit
+from FSEvents import (
     CFRunLoopGetCurrent,
     CFRunLoopRun,
     CFRunLoopStop,
@@ -73,7 +72,7 @@
 logger = logging.getLogger(__name__)
 
 message = "watchdog.observers.fsevents2 is deprecated and will be removed in a future release."
-warnings.warn(message, DeprecationWarning)
+warnings.warn(message, category=DeprecationWarning)
 logger.warning(message)
 
 
@@ -126,19 +125,16 @@ def stop(self):
 
     def _callback(self, streamRef, clientCallBackInfo, numEvents, eventPaths, eventFlags, eventIDs):
         events = [NativeEvent(path, flags, _id) for path, flags, _id in zip(eventPaths, eventFlags, eventIDs)]
-        logger.debug(f"FSEvents callback. Got {numEvents} events:")
+        logger.debug("FSEvents callback. Got %d events:", numEvents)
         for e in events:
             logger.debug(e)
         self._queue.put(events)
 
     def read_events(self):
-        """
-        Returns a list or one or more events, or None if there are no more
+        """Returns a list or one or more events, or None if there are no more
         events to be read.
         """
-        if not self.is_alive():
-            return None
-        return self._queue.get()
+        return self._queue.get() if self.is_alive() else None
 
 
 class NativeEvent:
@@ -181,9 +177,7 @@ def __repr__(self):
 
 
 class FSEventsEmitter(EventEmitter):
-    """
-    FSEvents based event emitter. Handles conversion of native events.
-    """
+    """FSEvents based event emitter. Handles conversion of native events."""
 
     def __init__(self, event_queue, watch, timeout=DEFAULT_EMITTER_TIMEOUT, event_filter=None):
         super().__init__(event_queue, watch, timeout, event_filter)
@@ -246,4 +240,4 @@ def queue_events(self, timeout):
 
 class FSEventsObserver2(BaseObserver):
     def __init__(self, timeout=DEFAULT_OBSERVER_TIMEOUT):
-        super().__init__(emitter_class=FSEventsEmitter, timeout=timeout)
+        super().__init__(FSEventsEmitter, timeout=timeout)
diff --git a/src/watchdog/observers/inotify.py b/src/watchdog/observers/inotify.py
index 754ca5a..f45e339 100644
--- a/src/watchdog/observers/inotify.py
+++ b/src/watchdog/observers/inotify.py
@@ -13,8 +13,7 @@
 # See the License for the specific language governing permissions and
 # limitations under the License.
 
-"""
-:module: watchdog.observers.inotify
+""":module: watchdog.observers.inotify
 :synopsis: ``inotify(7)`` based emitter implementation.
 :author: Sebastien Martini <seb@dbzteam.org>
 :author: Luke McCarthy <luke@iogopro.co.uk>
@@ -95,8 +94,7 @@
 
 
 class InotifyEmitter(EventEmitter):
-    """
-    inotify(7)-based event emitter.
+    """inotify(7)-based event emitter.
 
     :param event_queue:
         The event queue to fill with events.
@@ -169,10 +167,7 @@ def queue_events(self, timeout, full_events=False):
                 if event.is_directory and self.watch.is_recursive:
                     for sub_event in generate_sub_created_events(src_path):
                         self.queue_event(sub_event)
-            elif event.is_attrib:
-                cls = DirModifiedEvent if event.is_directory else FileModifiedEvent
-                self.queue_event(cls(src_path))
-            elif event.is_modify:
+            elif event.is_attrib or event.is_modify:
                 cls = DirModifiedEvent if event.is_directory else FileModifiedEvent
                 self.queue_event(cls(src_path))
             elif event.is_delete or (event.is_moved_from and not full_events):
@@ -221,9 +216,13 @@ def get_event_mask_from_filter(self):
             elif cls in (DirCreatedEvent, FileCreatedEvent):
                 event_mask |= InotifyConstants.IN_MOVE | InotifyConstants.IN_CREATE
             elif cls is DirModifiedEvent:
-                event_mask |= (InotifyConstants.IN_MOVE | InotifyConstants.IN_ATTRIB |
-                               InotifyConstants.IN_MODIFY | InotifyConstants.IN_CREATE |
-                               InotifyConstants.IN_CLOSE_WRITE)
+                event_mask |= (
+                    InotifyConstants.IN_MOVE
+                    | InotifyConstants.IN_ATTRIB
+                    | InotifyConstants.IN_MODIFY
+                    | InotifyConstants.IN_CREATE
+                    | InotifyConstants.IN_CLOSE_WRITE
+                )
             elif cls is FileModifiedEvent:
                 event_mask |= InotifyConstants.IN_ATTRIB | InotifyConstants.IN_MODIFY
             elif cls in (DirDeletedEvent, FileDeletedEvent):
@@ -236,8 +235,7 @@ def get_event_mask_from_filter(self):
 
 
 class InotifyFullEmitter(InotifyEmitter):
-    """
-    inotify(7)-based event emitter. By default this class produces move events even if they are not matched
+    """inotify(7)-based event emitter. By default this class produces move events even if they are not matched
     Such move events will have a ``None`` value for the unmatched part.
 
     :param event_queue:
@@ -250,21 +248,25 @@ class InotifyFullEmitter(InotifyEmitter):
         Read events blocking timeout (in seconds).
     :type timeout:
         ``float``
+    :param event_filter:
+        Collection of event types to emit, or None for no filtering (default).
+    :type event_filter:
+        Optional[Iterable[:class:`watchdog.events.FileSystemEvent`]]
+
     """
 
-    def __init__(self, event_queue, watch, timeout=DEFAULT_EMITTER_TIMEOUT):
-        super().__init__(event_queue, watch, timeout)
+    def __init__(self, event_queue, watch, timeout=DEFAULT_EMITTER_TIMEOUT, event_filter=None):
+        super().__init__(event_queue, watch, timeout, event_filter)
 
     def queue_events(self, timeout, events=True):
         InotifyEmitter.queue_events(self, timeout, full_events=events)
 
 
 class InotifyObserver(BaseObserver):
-    """
-    Observer thread that schedules watching directories and dispatches
+    """Observer thread that schedules watching directories and dispatches
     calls to event handlers.
     """
 
     def __init__(self, timeout=DEFAULT_OBSERVER_TIMEOUT, generate_full_events=False):
         cls = InotifyFullEmitter if generate_full_events else InotifyEmitter
-        super().__init__(emitter_class=cls, timeout=timeout)
+        super().__init__(cls, timeout=timeout)
diff --git a/src/watchdog/observers/inotify_c.py b/src/watchdog/observers/inotify_c.py
index aec29e2..0935aaa 100644
--- a/src/watchdog/observers/inotify_c.py
+++ b/src/watchdog/observers/inotify_c.py
@@ -15,6 +15,7 @@
 
 from __future__ import annotations
 
+import contextlib
 import ctypes
 import ctypes.util
 import errno
@@ -29,7 +30,7 @@
 libc = ctypes.CDLL(None)
 
 if not hasattr(libc, "inotify_init") or not hasattr(libc, "inotify_add_watch") or not hasattr(libc, "inotify_rm_watch"):
-    raise UnsupportedLibc(f"Unsupported libc version found: {libc._name}")
+    raise UnsupportedLibc(f"Unsupported libc version found: {libc._name}")  # noqa:SLF001
 
 inotify_add_watch = ctypes.CFUNCTYPE(c_int, c_int, c_char_p, c_uint32, use_errno=True)(("inotify_add_watch", libc))
 
@@ -113,8 +114,7 @@ class InotifyConstants:
 
 
 class inotify_event_struct(ctypes.Structure):
-    """
-    Structure representation of the inotify_event structure
+    """Structure representation of the inotify_event structure
     (used in buffer size calculations)::
 
         struct inotify_event {
@@ -126,13 +126,13 @@ class inotify_event_struct(ctypes.Structure):
         };
     """
 
-    _fields_ = [
+    _fields_ = (
         ("wd", c_int),
         ("mask", c_uint32),
         ("cookie", c_uint32),
         ("len", c_uint32),
         ("name", c_char_p),
-    ]
+    )
 
 
 EVENT_SIZE = ctypes.sizeof(inotify_event_struct)
@@ -141,8 +141,7 @@ class inotify_event_struct(ctypes.Structure):
 
 
 class Inotify:
-    """
-    Linux inotify(7) API wrapper class.
+    """Linux inotify(7) API wrapper class.
 
     :param path:
         The directory path for which we want an inotify object.
@@ -201,27 +200,24 @@ def clear_move_records(self):
         self._moved_from_events = {}
 
     def source_for_move(self, destination_event):
-        """
-        The source path corresponding to the given MOVED_TO event.
+        """The source path corresponding to the given MOVED_TO event.
 
         If the source path is outside the monitored directories, None
         is returned instead.
         """
         if destination_event.cookie in self._moved_from_events:
             return self._moved_from_events[destination_event.cookie].src_path
-        else:
-            return None
+
+        return None
 
     def remember_move_from_event(self, event):
-        """
-        Save this event as the source event for future MOVED_TO events to
+        """Save this event as the source event for future MOVED_TO events to
         reference.
         """
         self._moved_from_events[event.cookie] = event
 
     def add_watch(self, path):
-        """
-        Adds a watch for the given path.
+        """Adds a watch for the given path.
 
         :param path:
             Path to begin monitoring.
@@ -230,8 +226,7 @@ def add_watch(self, path):
             self._add_watch(path, self._event_mask)
 
     def remove_watch(self, path):
-        """
-        Removes a watch for the given path.
+        """Removes a watch for the given path.
 
         :param path:
             Path string for which the watch will be removed.
@@ -243,24 +238,18 @@ def remove_watch(self, path):
                 Inotify._raise_error()
 
     def close(self):
-        """
-        Closes the inotify instance and removes all associated watches.
-        """
+        """Closes the inotify instance and removes all associated watches."""
         with self._lock:
             if self._path in self._wd_for_path:
                 wd = self._wd_for_path[self._path]
                 inotify_rm_watch(self._inotify_fd, wd)
 
-            try:
+            # descriptor may be invalid because file was deleted
+            with contextlib.suppress(OSError):
                 os.close(self._inotify_fd)
-            except OSError:
-                # descriptor may be invalid because file was deleted
-                pass
 
     def read_events(self, event_buffer_size=DEFAULT_EVENT_BUFFER_SIZE):
-        """
-        Reads events from inotify and yields them.
-        """
+        """Reads events from inotify and yields them."""
         # HACK: We need to traverse the directory path
         # recursively and simulate events for newly
         # created subdirectories/files. This will handle
@@ -270,7 +259,7 @@ def _recursive_simulate(src_path):
             events = []
             for root, dirnames, filenames in os.walk(src_path):
                 for dirname in dirnames:
-                    try:
+                    with contextlib.suppress(OSError):
                         full_path = os.path.join(root, dirname)
                         wd_dir = self._add_watch(full_path, self._event_mask)
                         e = InotifyEvent(
@@ -281,8 +270,6 @@ def _recursive_simulate(src_path):
                             full_path,
                         )
                         events.append(e)
-                    except OSError:
-                        pass
                 for filename in filenames:
                     full_path = os.path.join(root, filename)
                     wd_parent_dir = self._wd_for_path[os.path.dirname(full_path)]
@@ -303,10 +290,11 @@ def _recursive_simulate(src_path):
             except OSError as e:
                 if e.errno == errno.EINTR:
                     continue
-                elif e.errno == errno.EBADF:
+
+                if e.errno == errno.EBADF:
                     return []
-                else:
-                    raise
+
+                raise
             break
 
         with self._lock:
@@ -328,7 +316,7 @@ def _recursive_simulate(src_path):
                         self._wd_for_path[inotify_event.src_path] = moved_wd
                         self._path_for_wd[moved_wd] = inotify_event.src_path
                         if self.is_recursive:
-                            for _path, _wd in self._wd_for_path.copy().items():
+                            for _path in self._wd_for_path.copy():
                                 if _path.startswith(move_src_path + os.path.sep.encode()):
                                     moved_wd = self._wd_for_path.pop(_path)
                                     _move_to_path = _path.replace(move_src_path, inotify_event.src_path)
@@ -364,8 +352,7 @@ def _recursive_simulate(src_path):
 
     # Non-synchronized methods.
     def _add_dir_watch(self, path, recursive, mask):
-        """
-        Adds a watch (optionally recursively) for the given directory path
+        """Adds a watch (optionally recursively) for the given directory path
         to monitor events specified by the mask.
 
         :param path:
@@ -387,8 +374,7 @@ def _add_dir_watch(self, path, recursive, mask):
                     self._add_watch(full_path, mask)
 
     def _add_watch(self, path, mask):
-        """
-        Adds a watch for the given path to monitor events specified by the
+        """Adds a watch for the given path to monitor events specified by the
         mask.
 
         :param path:
@@ -405,21 +391,21 @@ def _add_watch(self, path, mask):
 
     @staticmethod
     def _raise_error():
-        """
-        Raises errors for inotify failures.
-        """
+        """Raises errors for inotify failures."""
         err = ctypes.get_errno()
+
         if err == errno.ENOSPC:
             raise OSError(errno.ENOSPC, "inotify watch limit reached")
-        elif err == errno.EMFILE:
+
+        if err == errno.EMFILE:
             raise OSError(errno.EMFILE, "inotify instance limit reached")
-        elif err != errno.EACCES:
+
+        if err != errno.EACCES:
             raise OSError(err, os.strerror(err))
 
     @staticmethod
     def _parse_event_buffer(event_buffer):
-        """
-        Parses an event buffer of ``inotify_event`` structs returned by
+        """Parses an event buffer of ``inotify_event`` structs returned by
         inotify::
 
             struct inotify_event {
@@ -443,8 +429,7 @@ def _parse_event_buffer(event_buffer):
 
 
 class InotifyEvent:
-    """
-    Inotify event struct wrapper.
+    """Inotify event struct wrapper.
 
     :param wd:
         Watch descriptor
diff --git a/src/watchdog/observers/kqueue.py b/src/watchdog/observers/kqueue.py
index 6b54f5e..7233af3 100644
--- a/src/watchdog/observers/kqueue.py
+++ b/src/watchdog/observers/kqueue.py
@@ -13,17 +13,7 @@
 # See the License for the specific language governing permissions and
 # limitations under the License.
 
-from __future__ import annotations
-
-# The `select` module varies between platforms.
-# mypy may complain about missing module attributes
-# depending on which platform it's running on.
-# The comment below disables mypy's attribute check.
-#
-# mypy: disable-error-code=attr-defined
-#
-"""
-:module: watchdog.observers.kqueue
+""":module: watchdog.observers.kqueue
 :synopsis: ``kqueue(2)`` based emitter implementation.
 :author: yesudeep@google.com (Yesudeep Mangalapilly)
 :author: contact@tiger-222.fr (Mickaël Schoentgen)
@@ -74,6 +64,15 @@
 
 """
 
+
+# The `select` module varies between platforms.
+# mypy may complain about missing module attributes depending on which platform it's running on.
+# The comment below disables mypy's attribute check.
+# mypy: disable-error-code=attr-defined
+
+from __future__ import annotations
+
+import contextlib
 import errno
 import os
 import os.path
@@ -106,10 +105,7 @@
 O_EVTONLY = 0x8000
 
 # Pre-calculated values for the kevent filter, flags, and fflags attributes.
-if platform.is_darwin():
-    WATCHDOG_OS_OPEN_FLAGS = O_EVTONLY
-else:
-    WATCHDOG_OS_OPEN_FLAGS = os.O_RDONLY | os.O_NONBLOCK
+WATCHDOG_OS_OPEN_FLAGS = O_EVTONLY if platform.is_darwin() else os.O_RDONLY | os.O_NONBLOCK
 WATCHDOG_KQ_FILTER = select.KQ_FILTER_VNODE
 WATCHDOG_KQ_EV_FLAGS = select.KQ_EV_ADD | select.KQ_EV_ENABLE | select.KQ_EV_CLEAR
 WATCHDOG_KQ_FFLAGS = (
@@ -152,45 +148,37 @@ def is_renamed(kev):
 
 
 class KeventDescriptorSet:
-
-    """
-    Thread-safe kevent descriptor collection.
-    """
+    """Thread-safe kevent descriptor collection."""
 
     def __init__(self):
         # Set of KeventDescriptor
         self._descriptors = set()
 
         # Descriptor for a given path.
-        self._descriptor_for_path = dict()
+        self._descriptor_for_path = {}
 
         # Descriptor for a given fd.
-        self._descriptor_for_fd = dict()
+        self._descriptor_for_fd = {}
 
         # List of kevent objects.
-        self._kevents = list()
+        self._kevents = []
 
         self._lock = threading.Lock()
 
     @property
     def kevents(self):
-        """
-        List of kevents monitored.
-        """
+        """List of kevents monitored."""
         with self._lock:
             return self._kevents
 
     @property
     def paths(self):
-        """
-        List of paths for which kevents have been created.
-        """
+        """List of paths for which kevents have been created."""
         with self._lock:
             return list(self._descriptor_for_path.keys())
 
     def get_for_fd(self, fd):
-        """
-        Given a file descriptor, returns the kevent descriptor object
+        """Given a file descriptor, returns the kevent descriptor object
         for it.
 
         :param fd:
@@ -204,8 +192,7 @@ def get_for_fd(self, fd):
             return self._descriptor_for_fd[fd]
 
     def get(self, path):
-        """
-        Obtains a :class:`KeventDescriptor` object for the specified path.
+        """Obtains a :class:`KeventDescriptor` object for the specified path.
 
         :param path:
             Path for which the descriptor will be obtained.
@@ -215,8 +202,7 @@ def get(self, path):
             return self._get(path)
 
     def __contains__(self, path):
-        """
-        Determines whether a :class:`KeventDescriptor has been registered
+        """Determines whether a :class:`KeventDescriptor has been registered
         for the specified path.
 
         :param path:
@@ -227,8 +213,7 @@ def __contains__(self, path):
             return self._has_path(path)
 
     def add(self, path, is_directory):
-        """
-        Adds a :class:`KeventDescriptor` to the collection for the given
+        """Adds a :class:`KeventDescriptor` to the collection for the given
         path.
 
         :param path:
@@ -245,8 +230,7 @@ def add(self, path, is_directory):
                 self._add_descriptor(KeventDescriptor(path, is_directory))
 
     def remove(self, path):
-        """
-        Removes the :class:`KeventDescriptor` object for the given path
+        """Removes the :class:`KeventDescriptor` object for the given path
         if it already exists.
 
         :param path:
@@ -259,9 +243,7 @@ def remove(self, path):
                 self._remove_descriptor(self._get(path))
 
     def clear(self):
-        """
-        Clears the collection and closes all open descriptors.
-        """
+        """Clears the collection and closes all open descriptors."""
         with self._lock:
             for descriptor in self._descriptors:
                 descriptor.close()
@@ -277,12 +259,12 @@ def _get(self, path):
 
     def _has_path(self, path):
         """Determines whether a :class:`KeventDescriptor` for the specified
-        path exists already in the collection."""
+        path exists already in the collection.
+        """
         return path in self._descriptor_for_path
 
     def _add_descriptor(self, descriptor):
-        """
-        Adds a descriptor to the collection.
+        """Adds a descriptor to the collection.
 
         :param descriptor:
             An instance of :class:`KeventDescriptor` to be added.
@@ -293,8 +275,7 @@ def _add_descriptor(self, descriptor):
         self._descriptor_for_fd[descriptor.fd] = descriptor
 
     def _remove_descriptor(self, descriptor):
-        """
-        Removes a descriptor from the collection.
+        """Removes a descriptor from the collection.
 
         :param descriptor:
             An instance of :class:`KeventDescriptor` to be removed.
@@ -307,9 +288,7 @@ def _remove_descriptor(self, descriptor):
 
 
 class KeventDescriptor:
-
-    """
-    A kevent descriptor convenience data structure to keep together:
+    """A kevent descriptor convenience data structure to keep together:
 
         * kevent
         * directory status
@@ -360,13 +339,9 @@ def is_directory(self):
         return self._is_directory
 
     def close(self):
-        """
-        Closes the file descriptor associated with a kevent descriptor.
-        """
-        try:
+        """Closes the file descriptor associated with a kevent descriptor."""
+        with contextlib.suppress(OSError):
             os.close(self.fd)
-        except OSError:
-            pass
 
     @property
     def key(self):
@@ -386,9 +361,7 @@ def __repr__(self):
 
 
 class KqueueEmitter(EventEmitter):
-
-    """
-    kqueue(2)-based event emitter.
+    """kqueue(2)-based event emitter.
 
     .. ADMONITION:: About ``kqueue(2)`` behavior and this implementation
 
@@ -452,8 +425,7 @@ def custom_stat(path, self=self):
         self._snapshot = DirectorySnapshot(watch.path, recursive=watch.is_recursive, stat=custom_stat)
 
     def _register_kevent(self, path, is_directory):
-        """
-        Registers a kevent descriptor for the given path.
+        """Registers a kevent descriptor for the given path.
 
         :param path:
             Path for which a kevent descriptor will be created.
@@ -495,8 +467,7 @@ def _register_kevent(self, path, is_directory):
                 raise
 
     def _unregister_kevent(self, path):
-        """
-        Convenience function to close the kevent descriptor for a
+        """Convenience function to close the kevent descriptor for a
         specified kqueue-monitored path.
 
         :param path:
@@ -505,8 +476,7 @@ def _unregister_kevent(self, path):
         self._descriptors.remove(path)
 
     def queue_event(self, event):
-        """
-        Handles queueing a single event object.
+        """Handles queueing a single event object.
 
         :param event:
             An instance of :class:`watchdog.events.FileSystemEvent`
@@ -526,8 +496,7 @@ def queue_event(self, event):
             self._unregister_kevent(event.src_path)
 
     def _gen_kqueue_events(self, kev, ref_snapshot, new_snapshot):
-        """
-        Generate events from the kevent list returned from the call to
+        """Generate events from the kevent list returned from the call to
         :meth:`select.kqueue.control`.
 
         .. NOTE:: kqueue only tells us about deletions, file modifications,
@@ -543,8 +512,7 @@ def _gen_kqueue_events(self, kev, ref_snapshot, new_snapshot):
             # Kqueue does not specify the destination names for renames
             # to, so we have to process these using the a snapshot
             # of the directory.
-            for event in self._gen_renamed_events(src_path, descriptor.is_directory, ref_snapshot, new_snapshot):
-                yield event
+            yield from self._gen_renamed_events(src_path, descriptor.is_directory, ref_snapshot, new_snapshot)
         elif is_attrib_modified(kev):
             if descriptor.is_directory:
                 yield DirModifiedEvent(src_path)
@@ -567,14 +535,11 @@ def _gen_kqueue_events(self, kev, ref_snapshot, new_snapshot):
                 yield FileDeletedEvent(src_path)
 
     def _parent_dir_modified(self, src_path):
-        """
-        Helper to generate a DirModifiedEvent on the parent of src_path.
-        """
+        """Helper to generate a DirModifiedEvent on the parent of src_path."""
         return DirModifiedEvent(os.path.dirname(src_path))
 
     def _gen_renamed_events(self, src_path, is_directory, ref_snapshot, new_snapshot):
-        """
-        Compares information from two directory snapshots (one taken before
+        """Compares information from two directory snapshots (one taken before
         the rename operation and another taken right after) to determine the
         destination path of the file system object renamed, and yields
         the appropriate events to be queued.
@@ -599,21 +564,18 @@ def _gen_renamed_events(self, src_path, is_directory, ref_snapshot, new_snapshot
         if dest_path is not None:
             dest_path = absolute_path(dest_path)
             if is_directory:
-                event = DirMovedEvent(src_path, dest_path)
-                yield event
+                yield DirMovedEvent(src_path, dest_path)
             else:
                 yield FileMovedEvent(src_path, dest_path)
             yield self._parent_dir_modified(src_path)
             yield self._parent_dir_modified(dest_path)
-            if is_directory:
+            if is_directory and self.watch.is_recursive:
                 # TODO: Do we need to fire moved events for the items
                 # inside the directory tree? Does kqueue does this
                 # all by itself? Check this and then enable this code
                 # only if it doesn't already.
                 # A: It doesn't. So I've enabled this block.
-                if self.watch.is_recursive:
-                    for sub_event in generate_sub_moved_events(src_path, dest_path):
-                        yield sub_event
+                yield from generate_sub_moved_events(src_path, dest_path)
         else:
             # If the new snapshot does not have an inode for the
             # old path, we haven't found the new name. Therefore,
@@ -625,8 +587,7 @@ def _gen_renamed_events(self, src_path, is_directory, ref_snapshot, new_snapshot
             yield self._parent_dir_modified(src_path)
 
     def _read_events(self, timeout=None):
-        """
-        Reads events from a call to the blocking
+        """Reads events from a call to the blocking
         :meth:`select.kqueue.control()` method.
 
         :param timeout:
@@ -637,8 +598,7 @@ def _read_events(self, timeout=None):
         return self._kq.control(self._descriptors.kevents, MAX_EVENTS, timeout)
 
     def queue_events(self, timeout):
-        """
-        Queues events by reading them from a call to the blocking
+        """Queues events by reading them from a call to the blocking
         :meth:`select.kqueue.control()` method.
 
         :param timeout:
@@ -683,11 +643,9 @@ def on_thread_stop(self):
 
 
 class KqueueObserver(BaseObserver):
-
-    """
-    Observer thread that schedules watching directories and dispatches
+    """Observer thread that schedules watching directories and dispatches
     calls to event handlers.
     """
 
     def __init__(self, timeout=DEFAULT_OBSERVER_TIMEOUT):
-        super().__init__(emitter_class=KqueueEmitter, timeout=timeout)
+        super().__init__(KqueueEmitter, timeout=timeout)
diff --git a/src/watchdog/observers/polling.py b/src/watchdog/observers/polling.py
index 466cd82..1bea14b 100644
--- a/src/watchdog/observers/polling.py
+++ b/src/watchdog/observers/polling.py
@@ -14,8 +14,7 @@
 # limitations under the License.
 
 
-"""
-:module: watchdog.observers.polling
+""":module: watchdog.observers.polling
 :synopsis: Polling emitter implementation.
 :author: yesudeep@google.com (Yesudeep Mangalapilly)
 :author: contact@tiger-222.fr (Mickaël Schoentgen)
@@ -49,12 +48,11 @@
     FileMovedEvent,
 )
 from watchdog.observers.api import DEFAULT_EMITTER_TIMEOUT, DEFAULT_OBSERVER_TIMEOUT, BaseObserver, EventEmitter
-from watchdog.utils.dirsnapshot import DirectorySnapshot, DirectorySnapshotDiff
+from watchdog.utils.dirsnapshot import DirectorySnapshot, DirectorySnapshotDiff, EmptyDirectorySnapshot
 
 
 class PollingEmitter(EventEmitter):
-    """
-    Platform-independent emitter that polls a directory to detect file
+    """Platform-independent emitter that polls a directory to detect file
     system changes.
     """
 
@@ -68,10 +66,13 @@ def __init__(
         listdir=os.scandir,
     ):
         super().__init__(event_queue, watch, timeout, event_filter)
-        self._snapshot = None
+        self._snapshot: DirectorySnapshot = EmptyDirectorySnapshot()
         self._lock = threading.Lock()
         self._take_snapshot = lambda: DirectorySnapshot(
-            self.watch.path, self.watch.is_recursive, stat=stat, listdir=listdir
+            self.watch.path,
+            self.watch.is_recursive,
+            stat=stat,
+            listdir=listdir,
         )
 
     def on_thread_start(self):
@@ -121,26 +122,22 @@ def queue_events(self, timeout):
 
 
 class PollingObserver(BaseObserver):
-    """
-    Platform-independent observer that polls a directory to detect file
+    """Platform-independent observer that polls a directory to detect file
     system changes.
     """
 
     def __init__(self, timeout=DEFAULT_OBSERVER_TIMEOUT):
-        super().__init__(emitter_class=PollingEmitter, timeout=timeout)
+        super().__init__(PollingEmitter, timeout=timeout)
 
 
 class PollingObserverVFS(BaseObserver):
-    """
-    File system independent observer that polls a directory to detect changes.
-    """
+    """File system independent observer that polls a directory to detect changes."""
 
     def __init__(self, stat, listdir, polling_interval=1):
-        """
-        :param stat: stat function. See ``os.stat`` for details.
+        """:param stat: stat function. See ``os.stat`` for details.
         :param listdir: listdir function. See ``os.scandir`` for details.
         :type polling_interval: float
         :param polling_interval: interval in seconds between polling the file system.
         """
         emitter_cls = partial(PollingEmitter, stat=stat, listdir=listdir)
-        super().__init__(emitter_class=emitter_cls, timeout=polling_interval)
+        super().__init__(emitter_cls, timeout=polling_interval)
diff --git a/src/watchdog/observers/read_directory_changes.py b/src/watchdog/observers/read_directory_changes.py
index fe038cc..be84bac 100644
--- a/src/watchdog/observers/read_directory_changes.py
+++ b/src/watchdog/observers/read_directory_changes.py
@@ -18,7 +18,6 @@
 
 import os.path
 import platform
-import sys
 import threading
 
 from watchdog.events import (
@@ -34,18 +33,14 @@
     generate_sub_moved_events,
 )
 from watchdog.observers.api import DEFAULT_EMITTER_TIMEOUT, DEFAULT_OBSERVER_TIMEOUT, BaseObserver, EventEmitter
-
-assert sys.platform.startswith("win"), f"{__name__} requires Windows"
-
-from watchdog.observers.winapi import close_directory_handle, get_directory_handle, read_events  # noqa: E402
+from watchdog.observers.winapi import close_directory_handle, get_directory_handle, read_events
 
 # Obsolete constant, it's no more used since v4.0.0.
 WATCHDOG_TRAVERSE_MOVED_DIR_DELAY = 1  # seconds
 
 
 class WindowsApiEmitter(EventEmitter):
-    """
-    Windows API-based emitter that uses ReadDirectoryChangesW
+    """Windows API-based emitter that uses ReadDirectoryChangesW
     to detect file system changes for a watch.
     """
 
@@ -110,10 +105,9 @@ def queue_events(self, timeout):
 
 
 class WindowsApiObserver(BaseObserver):
-    """
-    Observer thread that schedules watching directories and dispatches
+    """Observer thread that schedules watching directories and dispatches
     calls to event handlers.
     """
 
     def __init__(self, timeout=DEFAULT_OBSERVER_TIMEOUT):
-        super().__init__(emitter_class=WindowsApiEmitter, timeout=timeout)
+        super().__init__(WindowsApiEmitter, timeout=timeout)
diff --git a/src/watchdog/observers/winapi.py b/src/watchdog/observers/winapi.py
index a4956c1..b857652 100644
--- a/src/watchdog/observers/winapi.py
+++ b/src/watchdog/observers/winapi.py
@@ -36,13 +36,10 @@
 
 from __future__ import annotations
 
-import sys
+import ctypes.wintypes
 from dataclasses import dataclass
 from functools import reduce
 
-assert sys.platform.startswith("win"), f"{__name__} requires Windows"
-import ctypes.wintypes  # noqa: E402
-
 LPVOID = ctypes.wintypes.LPVOID
 
 # Invalid handle value.
@@ -95,14 +92,14 @@
 
 
 class OVERLAPPED(ctypes.Structure):
-    _fields_ = [
+    _fields_ = (
         ("Internal", LPVOID),
         ("InternalHigh", LPVOID),
         ("Offset", ctypes.wintypes.DWORD),
         ("OffsetHigh", ctypes.wintypes.DWORD),
         ("Pointer", LPVOID),
         ("hEvent", ctypes.wintypes.HANDLE),
-    ]
+    )
 
 
 def _errcheck_bool(value, func, args):
@@ -234,13 +231,13 @@ def _errcheck_dword(value, func, args):
 
 
 class FILE_NOTIFY_INFORMATION(ctypes.Structure):
-    _fields_ = [
+    _fields_ = (
         ("NextEntryOffset", ctypes.wintypes.DWORD),
         ("Action", ctypes.wintypes.DWORD),
         ("FileNameLength", ctypes.wintypes.DWORD),
         # ("FileName", (ctypes.wintypes.WCHAR * 1))]
         ("FileName", (ctypes.c_char * 1)),
-    ]
+    )
 
 
 LPFNI = ctypes.POINTER(FILE_NOTIFY_INFORMATION)
@@ -369,7 +366,7 @@ def read_directory_changes(handle, path, recursive):
         if _is_observed_path_deleted(handle, path):
             return _generate_observed_path_deleted_event()
 
-        raise e
+        raise
 
     return event_buffer.raw, int(nbytes.value)
 
diff --git a/src/watchdog/tricks/__init__.py b/src/watchdog/tricks/__init__.py
index 65bf074..5395443 100644
--- a/src/watchdog/tricks/__init__.py
+++ b/src/watchdog/tricks/__init__.py
@@ -13,8 +13,7 @@
 # See the License for the specific language governing permissions and
 # limitations under the License.
 
-"""
-:module: watchdog.tricks
+""":module: watchdog.tricks
 :synopsis: Utility event handlers.
 :author: yesudeep@google.com (Yesudeep Mangalapilly)
 :author: contact@tiger-222.fr (Mickaël Schoentgen)
@@ -41,17 +40,17 @@
 
 from __future__ import annotations
 
+import contextlib
 import functools
 import logging
 import os
 import signal
 import subprocess
-import sys
 import threading
 import time
 
 from watchdog.events import EVENT_TYPE_OPENED, FileSystemEvent, PatternMatchingEventHandler
-from watchdog.utils import echo
+from watchdog.utils import echo, platform
 from watchdog.utils.event_debouncer import EventDebouncer
 from watchdog.utils.process_watcher import ProcessWatcher
 
@@ -60,7 +59,6 @@
 
 
 class Trick(PatternMatchingEventHandler):
-
     """Your tricks should subclass this class."""
 
     @classmethod
@@ -80,7 +78,6 @@ def generate_yaml(cls):
 
 
 class LoggerTrick(Trick):
-
     """A simple trick that does only logs events."""
 
     @echo_events
@@ -89,7 +86,6 @@ def on_any_event(self, event: FileSystemEvent) -> None:
 
 
 class ShellCommandTrick(Trick):
-
     """Executes shell commands in response to matched events."""
 
     def __init__(
@@ -150,7 +146,8 @@ def on_any_event(self, event):
             process_watcher = ProcessWatcher(self.process, None)
             self._process_watchers.add(process_watcher)
             process_watcher.process_termination_callback = functools.partial(
-                self._process_watchers.discard, process_watcher
+                self._process_watchers.discard,
+                process_watcher,
             )
             process_watcher.start()
 
@@ -159,7 +156,6 @@ def is_process_running(self):
 
 
 class AutoRestartTrick(Trick):
-
     """Starts a long-running subprocess and restarts it on matched events.
 
     The command parameter is a list of command arguments, such as
@@ -268,11 +264,9 @@ def _stop_process(self):
                             break
                         time.sleep(0.25)
                     else:
-                        try:
+                        # Process is already gone
+                        with contextlib.suppress(OSError):
                             kill_process(self.process.pid, 9)
-                        except OSError:
-                            # Process is already gone
-                            pass
                 self.process = None
         finally:
             self._is_process_stopping = False
@@ -296,7 +290,7 @@ def _restart_process(self):
         self.restart_count += 1
 
 
-if not sys.platform.startswith("win"):
+if not platform.is_windows():
 
     def kill_process(pid, stop_signal):
         os.killpg(os.getpgid(pid), stop_signal)
diff --git a/src/watchdog/utils/__init__.py b/src/watchdog/utils/__init__.py
index aea5f4e..ece812f 100644
--- a/src/watchdog/utils/__init__.py
+++ b/src/watchdog/utils/__init__.py
@@ -14,8 +14,7 @@
 # limitations under the License.
 
 
-"""
-:module: watchdog.utils
+""":module: watchdog.utils
 :synopsis: Utility classes and functions.
 :author: yesudeep@google.com (Yesudeep Mangalapilly)
 :author: contact@tiger-222.fr (Mickaël Schoentgen)
@@ -28,6 +27,7 @@
    :inherited-members:
 
 """
+
 from __future__ import annotations
 
 import sys
@@ -40,11 +40,7 @@ class UnsupportedLibc(Exception):
 
 
 class WatchdogShutdown(Exception):
-    """
-    Semantic exception used to signal an external shutdown event.
-    """
-
-    pass
+    """Semantic exception used to signal an external shutdown event."""
 
 
 class BaseThread(threading.Thread):
@@ -72,7 +68,6 @@ def on_thread_stop(self):
 
         This method is called immediately after the thread is signaled to stop.
         """
-        pass
 
     def stop(self):
         """Signals the thread to stop."""
@@ -84,9 +79,8 @@ def on_thread_start(self):
         calls this method.
 
         This method is called right before this thread is started and this
-        object’s run() method is invoked.
+        object's run() method is invoked.
         """
-        pass
 
     def start(self):
         self.on_thread_start()
@@ -97,8 +91,8 @@ def load_module(module_name):
     """Imports a module given its name and returns a handle to it."""
     try:
         __import__(module_name)
-    except ImportError:
-        raise ImportError(f"No module named {module_name}")
+    except ImportError as e:
+        raise ImportError(f"No module named {module_name}") from e
     return sys.modules[module_name]
 
 
@@ -107,11 +101,13 @@ def load_class(dotted_path):
     specification the last part of the dotted path is the class name
     and there is at least one module name preceding the class name.
 
-    Notes:
+    Notes
+    -----
     You will need to ensure that the module you are trying to load
     exists in the Python path.
 
-    Examples:
+    Examples
+    --------
     - module.name.ClassName    # Provided module.name is in the Python path.
     - module.ClassName         # Provided module is in the Python path.
 
@@ -119,6 +115,7 @@ def load_class(dotted_path):
     - ClassName
     - modle.name.ClassName     # Typo in module name.
     - module.name.ClasNam      # Typo in classname.
+
     """
     dotted_path_split = dotted_path.split(".")
     if len(dotted_path_split) <= 1:
@@ -131,8 +128,8 @@ def load_class(dotted_path):
         return getattr(module, klass_name)
         # Finally create and return an instance of the class
         # return klass(*args, **kwargs)
-    else:
-        raise AttributeError(f"Module {module_name} does not have class attribute {klass_name}")
+
+    raise AttributeError(f"Module {module_name} does not have class attribute {klass_name}")
 
 
 if TYPE_CHECKING or sys.version_info >= (3, 8):
@@ -142,5 +139,4 @@ def load_class(dotted_path):
     # Provide a dummy Protocol class when not available from stdlib.  Should be used
     # only for hinting.  This could be had from typing_protocol, but not worth adding
     # the _first_ dependency just for this.
-    class Protocol:
-        ...
+    class Protocol: ...
diff --git a/src/watchdog/utils/bricks.py b/src/watchdog/utils/bricks.py
index 980a0ff..45a8043 100644
--- a/src/watchdog/utils/bricks.py
+++ b/src/watchdog/utils/bricks.py
@@ -14,8 +14,7 @@
 # limitations under the License.
 
 
-"""
-Utility collections or "bricks".
+"""Utility collections or "bricks".
 
 :module: watchdog.utils.bricks
 :author: yesudeep@google.com (Yesudeep Mangalapilly)
@@ -40,7 +39,6 @@
 
 
 class SkipRepeatsQueue(queue.Queue):
-
     """Thread-safe implementation of an special queue where a
     put of the last-item put'd will be dropped.
 
diff --git a/src/watchdog/utils/delayed_queue.py b/src/watchdog/utils/delayed_queue.py
index f3a5ead..e6f1183 100644
--- a/src/watchdog/utils/delayed_queue.py
+++ b/src/watchdog/utils/delayed_queue.py
@@ -76,9 +76,10 @@ def get(self) -> Optional[T]:
 
     def remove(self, predicate: Callable[[T], bool]) -> Optional[T]:
         """Remove and return the first items for which predicate is True,
-        ignoring delay."""
+        ignoring delay.
+        """
         with self._lock:
-            for i, (elem, t, delay) in enumerate(self._queue):
+            for i, (elem, *_) in enumerate(self._queue):
                 if predicate(elem):
                     del self._queue[i]
                     return elem
diff --git a/src/watchdog/utils/dirsnapshot.py b/src/watchdog/utils/dirsnapshot.py
index c1ff033..77f0318 100644
--- a/src/watchdog/utils/dirsnapshot.py
+++ b/src/watchdog/utils/dirsnapshot.py
@@ -14,8 +14,7 @@
 # See the License for the specific language governing permissions and
 # limitations under the License.
 
-"""
-:module: watchdog.utils.dirsnapshot
+""":module: watchdog.utils.dirsnapshot
 :synopsis: Directory snapshots and comparison.
 :author: yesudeep@google.com (Yesudeep Mangalapilly)
 :author: contact@tiger-222.fr (Mickaël Schoentgen)
@@ -48,14 +47,15 @@
 
 from __future__ import annotations
 
+import contextlib
 import errno
 import os
 from stat import S_ISDIR
+from typing import Any, Callable, Iterator, List, Optional, Tuple
 
 
 class DirectorySnapshotDiff:
-    """
-    Compares two directory snapshots and creates an object that represents
+    """Compares two directory snapshots and creates an object that represents
     the difference between the two snapshots.
 
     :param ref:
@@ -79,18 +79,23 @@ class DirectorySnapshotDiff:
         :class:`bool`
     """
 
-    def __init__(self, ref, snapshot, ignore_device=False):
+    def __init__(
+        self,
+        ref: DirectorySnapshot,
+        snapshot: DirectorySnapshot,
+        ignore_device: bool = False,
+    ):
         created = snapshot.paths - ref.paths
         deleted = ref.paths - snapshot.paths
 
         if ignore_device:
 
-            def get_inode(directory, full_path):
+            def get_inode(directory: DirectorySnapshot, full_path: str) -> int | Tuple[int, int]:
                 return directory.inode(full_path)[0]
 
         else:
 
-            def get_inode(directory, full_path):
+            def get_inode(directory: DirectorySnapshot, full_path: str) -> int | Tuple[int, int]:
                 return directory.inode(full_path)
 
         # check that all unchanged paths have the same inode
@@ -100,7 +105,7 @@ def get_inode(directory, full_path):
                 deleted.add(path)
 
         # find moved paths
-        moved = set()
+        moved: set[Tuple[str, str]] = set()
         for path in set(deleted):
             inode = ref.inode(path)
             new_path = snapshot.path(inode)
@@ -118,11 +123,12 @@ def get_inode(directory, full_path):
 
         # find modified paths
         # first check paths that have not moved
-        modified = set()
+        modified: set[str] = set()
         for path in ref.paths & snapshot.paths:
-            if get_inode(ref, path) == get_inode(snapshot, path):
-                if ref.mtime(path) != snapshot.mtime(path) or ref.size(path) != snapshot.size(path):
-                    modified.add(path)
+            if get_inode(ref, path) == get_inode(snapshot, path) and (
+                ref.mtime(path) != snapshot.mtime(path) or ref.size(path) != snapshot.size(path)
+            ):
+                modified.add(path)
 
         for old_path, new_path in moved:
             if ref.mtime(old_path) != snapshot.mtime(new_path) or ref.size(old_path) != snapshot.size(new_path):
@@ -138,10 +144,10 @@ def get_inode(directory, full_path):
         self._files_modified = list(modified - set(self._dirs_modified))
         self._files_moved = list(moved - set(self._dirs_moved))
 
-    def __str__(self):
+    def __str__(self) -> str:
         return self.__repr__()
 
-    def __repr__(self):
+    def __repr__(self) -> str:
         fmt = (
             "<{0} files(created={1}, deleted={2}, modified={3}, moved={4}),"
             " folders(created={5}, deleted={6}, modified={7}, moved={8})>"
@@ -159,24 +165,23 @@ def __repr__(self):
         )
 
     @property
-    def files_created(self):
+    def files_created(self) -> List[str]:
         """List of files that were created."""
         return self._files_created
 
     @property
-    def files_deleted(self):
+    def files_deleted(self) -> List[str]:
         """List of files that were deleted."""
         return self._files_deleted
 
     @property
-    def files_modified(self):
+    def files_modified(self) -> List[str]:
         """List of files that were modified."""
         return self._files_modified
 
     @property
-    def files_moved(self):
-        """
-        List of files that were moved.
+    def files_moved(self) -> list[Tuple[str, str]]:
+        """List of files that were moved.
 
         Each event is a two-tuple the first item of which is the path
         that has been renamed to the second item in the tuple.
@@ -184,16 +189,13 @@ def files_moved(self):
         return self._files_moved
 
     @property
-    def dirs_modified(self):
-        """
-        List of directories that were modified.
-        """
+    def dirs_modified(self) -> List[str]:
+        """List of directories that were modified."""
         return self._dirs_modified
 
     @property
-    def dirs_moved(self):
-        """
-        List of directories that were moved.
+    def dirs_moved(self) -> List[tuple[str, str]]:
+        """List of directories that were moved.
 
         Each event is a two-tuple the first item of which is the path
         that has been renamed to the second item in the tuple.
@@ -201,23 +203,84 @@ def dirs_moved(self):
         return self._dirs_moved
 
     @property
-    def dirs_deleted(self):
-        """
-        List of directories that were deleted.
-        """
+    def dirs_deleted(self) -> List[str]:
+        """List of directories that were deleted."""
         return self._dirs_deleted
 
     @property
-    def dirs_created(self):
-        """
-        List of directories that were created.
-        """
+    def dirs_created(self) -> List[str]:
+        """List of directories that were created."""
         return self._dirs_created
 
+    class ContextManager:
+        """Context manager that creates two directory snapshots and a
+        diff object that represents the difference between the two snapshots.
+
+        :param path:
+            The directory path for which a snapshot should be taken.
+        :type path:
+            ``str``
+        :param recursive:
+            ``True`` if the entire directory tree should be included in the
+            snapshot; ``False`` otherwise.
+        :type recursive:
+            ``bool``
+        :param stat:
+            Use custom stat function that returns a stat structure for path.
+            Currently only st_dev, st_ino, st_mode and st_mtime are needed.
+
+            A function taking a ``path`` as argument which will be called
+            for every entry in the directory tree.
+        :param listdir:
+            Use custom listdir function. For details see ``os.scandir``.
+        :param ignore_device:
+            A boolean indicating whether to ignore the device id or not.
+            By default, a file may be uniquely identified by a combination of its first
+            inode and its device id. The problem is that the device id may (or may not)
+            change between system boots. This problem would cause the DirectorySnapshotDiff
+            to think a file has been deleted and created again but it would be the
+            exact same file.
+            Set to True only if you are sure you will always use the same device.
+        :type ignore_device:
+            :class:`bool`
+        """
+
+        def __init__(
+            self,
+            path: str,
+            recursive: bool = True,
+            stat: Callable[[str], os.stat_result] = os.stat,
+            listdir: Callable[[Optional[str]], Iterator[os.DirEntry]] = os.scandir,
+            ignore_device: bool = False,
+        ):
+            self.path = path
+            self.recursive = recursive
+            self.stat = stat
+            self.listdir = listdir
+            self.ignore_device = ignore_device
+
+        def __enter__(self):
+            self.pre_snapshot = self.get_snapshot()
+
+        def __exit__(self, *args):
+            self.post_snapshot = self.get_snapshot()
+            self.diff = DirectorySnapshotDiff(
+                self.pre_snapshot,
+                self.post_snapshot,
+                ignore_device=self.ignore_device,
+            )
+
+        def get_snapshot(self):
+            return DirectorySnapshot(
+                path=self.path,
+                recursive=self.recursive,
+                stat=self.stat,
+                listdir=self.listdir,
+            )
+
 
 class DirectorySnapshot:
-    """
-    A snapshot of stat information of files in a directory.
+    """A snapshot of stat information of files in a directory.
 
     :param path:
         The directory path for which a snapshot should be taken.
@@ -238,13 +301,19 @@ class DirectorySnapshot:
         Use custom listdir function. For details see ``os.scandir``.
     """
 
-    def __init__(self, path, recursive=True, stat=os.stat, listdir=os.scandir):
+    def __init__(
+        self,
+        path: str,
+        recursive: bool = True,
+        stat: Callable[[str], os.stat_result] = os.stat,
+        listdir: Callable[[Optional[str]], Iterator[os.DirEntry]] = os.scandir,
+    ):
         self.recursive = recursive
         self.stat = stat
         self.listdir = listdir
 
-        self._stat_info = {}
-        self._inode_to_path = {}
+        self._stat_info: dict[str, os.stat_result] = {}
+        self._inode_to_path: dict[Tuple[int, int], str] = {}
 
         st = self.stat(path)
         self._stat_info[path] = st
@@ -255,7 +324,7 @@ def __init__(self, path, recursive=True, stat=os.stat, listdir=os.scandir):
             self._inode_to_path[i] = p
             self._stat_info[p] = st
 
-    def walk(self, root):
+    def walk(self, root: str) -> Iterator[Tuple[str, os.stat_result]]:
         try:
             paths = [os.path.join(root, entry.name) for entry in self.listdir(root)]
         except OSError as e:
@@ -270,52 +339,42 @@ def walk(self, root):
 
         entries = []
         for p in paths:
-            try:
+            with contextlib.suppress(OSError):
                 entry = (p, self.stat(p))
                 entries.append(entry)
                 yield entry
-            except OSError:
-                continue
 
         if self.recursive:
             for path, st in entries:
-                try:
+                with contextlib.suppress(PermissionError):
                     if S_ISDIR(st.st_mode):
-                        for entry in self.walk(path):
-                            yield entry
-                except PermissionError:
-                    pass
+                        yield from self.walk(path)
 
     @property
-    def paths(self):
-        """
-        Set of file/directory paths in the snapshot.
-        """
+    def paths(self) -> set[str]:
+        """Set of file/directory paths in the snapshot."""
         return set(self._stat_info.keys())
 
-    def path(self, id):
-        """
-        Returns path for id. None if id is unknown to this snapshot.
-        """
-        return self._inode_to_path.get(id)
+    def path(self, uid: Tuple[int, int]) -> Optional[str]:
+        """Returns path for id. None if id is unknown to this snapshot."""
+        return self._inode_to_path.get(uid)
 
-    def inode(self, path):
+    def inode(self, path: str) -> Tuple[int, int]:
         """Returns an id for path."""
         st = self._stat_info[path]
         return (st.st_ino, st.st_dev)
 
-    def isdir(self, path):
+    def isdir(self, path: str) -> bool:
         return S_ISDIR(self._stat_info[path].st_mode)
 
-    def mtime(self, path):
+    def mtime(self, path: str) -> float:
         return self._stat_info[path].st_mtime
 
-    def size(self, path):
+    def size(self, path: str) -> int:
         return self._stat_info[path].st_size
 
-    def stat_info(self, path):
-        """
-        Returns a stat information object for the specified path from
+    def stat_info(self, path: str) -> os.stat_result:
+        """Returns a stat information object for the specified path from
         the snapshot.
 
         Attached information is subject to change. Do not use unless
@@ -328,7 +387,7 @@ def stat_info(self, path):
         """
         return self._stat_info[path]
 
-    def __sub__(self, previous_dirsnap):
+    def __sub__(self, previous_dirsnap: DirectorySnapshot) -> DirectorySnapshotDiff:
         """Allow subtracting a DirectorySnapshot object instance from
         another.
 
@@ -337,31 +396,34 @@ def __sub__(self, previous_dirsnap):
         """
         return DirectorySnapshotDiff(previous_dirsnap, self)
 
-    def __str__(self):
+    def __str__(self) -> str:
         return self.__repr__()
 
-    def __repr__(self):
+    def __repr__(self) -> str:
         return str(self._stat_info)
 
 
-class EmptyDirectorySnapshot:
+class EmptyDirectorySnapshot(DirectorySnapshot):
     """Class to implement an empty snapshot. This is used together with
     DirectorySnapshot and DirectorySnapshotDiff in order to get all the files/folders
     in the directory as created.
     """
 
+    def __init__(self):
+        pass
+
     @staticmethod
-    def path(_):
+    def path(_: Any) -> None:
         """Mock up method to return the path of the received inode. As the snapshot
         is intended to be empty, it always returns None.
 
         :returns:
             None.
         """
-        return None
+        return
 
     @property
-    def paths(self):
+    def paths(self) -> set:
         """Mock up method to return a set of file/directory paths in the snapshot. As
         the snapshot is intended to be empty, it always returns an empty set.
 
diff --git a/src/watchdog/utils/echo.py b/src/watchdog/utils/echo.py
index 2e4ade9..73683c0 100644
--- a/src/watchdog/utils/echo.py
+++ b/src/watchdog/utils/echo.py
@@ -5,7 +5,7 @@
 #
 # Place into the public domain.
 
-""" Echo calls made to functions and methods in a module.
+"""Echo calls made to functions and methods in a module.
 
 "Echoing" a function call means printing out the name of the function
 and the values of its arguments before making the call (which is more
@@ -25,11 +25,14 @@
 decorated function will be echoed.
 
 Example:
-
+-------
   @echo.echo
   def my_function(args):
       pass
+
+
 """
+
 from __future__ import annotations
 
 import inspect
@@ -75,7 +78,7 @@ def method_name(method):
 def format_arg_value(arg_val):
     """Return a string representing a (name, value) pair.
 
-    >>> format_arg_value(('x', (1, 2, 3)))
+    >>> format_arg_value(("x", (1, 2, 3)))
     'x=(1, 2, 3)'
     """
     arg, val = arg_val
diff --git a/src/watchdog/utils/patterns.py b/src/watchdog/utils/patterns.py
index 0785c5c..f2db371 100644
--- a/src/watchdog/utils/patterns.py
+++ b/src/watchdog/utils/patterns.py
@@ -27,15 +27,14 @@ def _match_path(path, included_patterns, excluded_patterns, case_sensitive):
 
     common_patterns = included_patterns & excluded_patterns
     if common_patterns:
-        raise ValueError("conflicting patterns `{}` included and excluded".format(common_patterns))
+        raise ValueError(f"conflicting patterns `{common_patterns}` included and excluded")
     return any(path.match(p) for p in included_patterns) and not any(path.match(p) for p in excluded_patterns)
 
 
 def filter_paths(paths, included_patterns=None, excluded_patterns=None, case_sensitive=True):
-    """
-    Filters from a set of paths based on acceptable patterns and
+    """Filters from a set of paths based on acceptable patterns and
     ignorable patterns.
-    :param pathnames:
+    :param paths:
         A list of path names that will be filtered based on matching and
         ignored patterns.
     :param included_patterns:
@@ -51,37 +50,24 @@ def filter_paths(paths, included_patterns=None, excluded_patterns=None, case_sen
         A list of pathnames that matched the allowable patterns and passed
         through the ignored patterns.
     """
-    included = ["*"] if included_patterns is None else included_patterns
-    excluded = [] if excluded_patterns is None else excluded_patterns
+    included = set(["*"] if included_patterns is None else included_patterns)
+    excluded = set([] if excluded_patterns is None else excluded_patterns)
 
     for path in paths:
-        if _match_path(path, set(included), set(excluded), case_sensitive):
+        if _match_path(path, included, excluded, case_sensitive):
             yield path
 
 
 def match_any_paths(paths, included_patterns=None, excluded_patterns=None, case_sensitive=True):
-    """
-    Matches from a set of paths based on acceptable patterns and
+    """Matches from a set of paths based on acceptable patterns and
     ignorable patterns.
-    :param pathnames:
-        A list of path names that will be filtered based on matching and
-        ignored patterns.
-    :param included_patterns:
-        Allow filenames matching wildcard patterns specified in this list.
-        If no pattern list is specified, ["*"] is used as the default pattern,
-        which matches all files.
-    :param excluded_patterns:
-        Ignores filenames matching wildcard patterns specified in this list.
-        If no pattern list is specified, no files are ignored.
-    :param case_sensitive:
-        ``True`` if matching should be case-sensitive; ``False`` otherwise.
-    :returns:
-        ``True`` if any of the paths matches; ``False`` otherwise.
+    See ``filter_paths()`` for signature details.
     """
-    included = ["*"] if included_patterns is None else included_patterns
-    excluded = [] if excluded_patterns is None else excluded_patterns
-
-    for path in paths:
-        if _match_path(path, set(included), set(excluded), case_sensitive):
-            return True
-    return False
+    return any(
+        filter_paths(
+            paths,
+            included_patterns=included_patterns,
+            excluded_patterns=excluded_patterns,
+            case_sensitive=case_sensitive,
+        )
+    )
diff --git a/src/watchdog/utils/platform.py b/src/watchdog/utils/platform.py
index fefa542..0f6b05a 100644
--- a/src/watchdog/utils/platform.py
+++ b/src/watchdog/utils/platform.py
@@ -13,7 +13,6 @@
 # See the License for the specific language governing permissions and
 # limitations under the License.
 
-
 from __future__ import annotations
 
 import sys
@@ -28,14 +27,17 @@
 def get_platform_name():
     if sys.platform.startswith("win"):
         return PLATFORM_WINDOWS
-    elif sys.platform.startswith("darwin"):
+
+    if sys.platform.startswith("darwin"):
         return PLATFORM_DARWIN
-    elif sys.platform.startswith("linux"):
+
+    if sys.platform.startswith("linux"):
         return PLATFORM_LINUX
-    elif sys.platform.startswith(("dragonfly", "freebsd", "netbsd", "openbsd", "bsd")):
+
+    if sys.platform.startswith(("dragonfly", "freebsd", "netbsd", "openbsd", "bsd")):
         return PLATFORM_BSD
-    else:
-        return PLATFORM_UNKNOWN
+
+    return PLATFORM_UNKNOWN
 
 
 __platform__ = get_platform_name()
diff --git a/src/watchdog/utils/process_watcher.py b/src/watchdog/utils/process_watcher.py
index dd4ece5..46717bc 100644
--- a/src/watchdog/utils/process_watcher.py
+++ b/src/watchdog/utils/process_watcher.py
@@ -21,6 +21,7 @@ def run(self):
                 return
 
         try:
-            self.process_termination_callback()
+            if not self.stopped_event.is_set():
+                self.process_termination_callback()
         except Exception:
             logger.exception("Error calling process termination callback")
diff --git a/src/watchdog/version.py b/src/watchdog/version.py
index b912a8c..760a724 100644
--- a/src/watchdog/version.py
+++ b/src/watchdog/version.py
@@ -18,9 +18,9 @@
 
 # When updating this version number, please update the
 # ``docs/source/global.rst.inc`` file as well.
-VERSION_MAJOR = 3
+VERSION_MAJOR = 4
 VERSION_MINOR = 0
-VERSION_BUILD = 1
+VERSION_BUILD = 2
 VERSION_INFO = (VERSION_MAJOR, VERSION_MINOR, VERSION_BUILD)
 VERSION_STRING = f"{VERSION_MAJOR}.{VERSION_MINOR}.{VERSION_BUILD}"
 
diff --git a/src/watchdog/watchmedo.py b/src/watchdog/watchmedo.py
old mode 100755
new mode 100644
index 997de39..783d1e0
--- a/src/watchdog/watchmedo.py
+++ b/src/watchdog/watchmedo.py
@@ -14,8 +14,7 @@
 # See the License for the specific language governing permissions and
 # limitations under the License.
 
-"""
-:module: watchdog.watchmedo
+""":module: watchdog.watchmedo
 :author: yesudeep@google.com (Yesudeep Mangalapilly)
 :author: contact@tiger-222.fr (Mickaël Schoentgen)
 :synopsis: ``watchmedo`` shell script utility.
@@ -34,10 +33,12 @@
 from textwrap import dedent
 from typing import TYPE_CHECKING
 
-from watchdog.observers.api import BaseObserverSubclassCallable
-from watchdog.utils import WatchdogShutdown, load_class
+from watchdog.utils import WatchdogShutdown, load_class, platform
 from watchdog.version import VERSION_STRING
 
+if TYPE_CHECKING:
+    from watchdog.observers.api import BaseObserverSubclassCallable
+
 logging.basicConfig(level=logging.INFO)
 
 CONFIG_KEY_TRICKS = "tricks"
@@ -111,8 +112,7 @@ def decorator(func):
 
 
 def path_split(pathname_spec, separator=os.pathsep):
-    """
-    Splits a pathname specification separated by an OS-dependent separator.
+    """Splits a pathname specification separated by an OS-dependent separator.
 
     :param pathname_spec:
         The pathname specification.
@@ -123,8 +123,7 @@ def path_split(pathname_spec, separator=os.pathsep):
 
 
 def add_to_sys_path(pathnames, index=0):
-    """
-    Adds specified paths at specified index into the sys.path list.
+    """Adds specified paths at specified index into the sys.path list.
 
     :param paths:
         A list of paths to add to the sys.path
@@ -137,8 +136,7 @@ def add_to_sys_path(pathnames, index=0):
 
 
 def load_config(tricks_file_pathname):
-    """
-    Loads the YAML configuration from the specified file.
+    """Loads the YAML configuration from the specified file.
 
     :param tricks_file_path:
         The path to the tricks configuration file.
@@ -152,8 +150,7 @@ def load_config(tricks_file_pathname):
 
 
 def parse_patterns(patterns_spec, ignore_patterns_spec, separator=";"):
-    """
-    Parses pattern argument specs and returns a two-tuple of
+    """Parses pattern argument specs and returns a two-tuple of
     (patterns, ignore_patterns).
     """
     patterns = patterns_spec.split(separator)
@@ -164,8 +161,7 @@ def parse_patterns(patterns_spec, ignore_patterns_spec, separator=";"):
 
 
 def observe_with(observer, event_handler, pathnames, recursive):
-    """
-    Single observer thread with a scheduled path and event handler.
+    """Single observer thread with a scheduled path and event handler.
 
     :param observer:
         The observer thread.
@@ -188,8 +184,7 @@ def observe_with(observer, event_handler, pathnames, recursive):
 
 
 def schedule_tricks(observer, tricks, pathname, recursive):
-    """
-    Schedules tricks with the specified observer and for the given watch
+    """Schedules tricks with the specified observer and for the given watch
     path.
 
     :param observer:
@@ -256,15 +251,13 @@ def schedule_tricks(observer, tricks, pathname, recursive):
     cmd_aliases=["tricks"],
 )
 def tricks_from(args):
-    """
-    Command to execute tricks from a tricks configuration file.
-    """
+    """Command to execute tricks from a tricks configuration file."""
     Observer: BaseObserverSubclassCallable
     if args.debug_force_polling:
         from watchdog.observers.polling import PollingObserver as Observer
     elif args.debug_force_kqueue:
         from watchdog.observers.kqueue import KqueueObserver as Observer
-    elif (not TYPE_CHECKING and args.debug_force_winapi) or (TYPE_CHECKING and sys.platform.startswith("win")):
+    elif (not TYPE_CHECKING and args.debug_force_winapi) or (TYPE_CHECKING and platform.is_windows()):
         from watchdog.observers.read_directory_changes import WindowsApiObserver as Observer
     elif args.debug_force_inotify:
         from watchdog.observers.inotify import InotifyObserver as Observer
@@ -287,15 +280,13 @@ def tricks_from(args):
 
         try:
             tricks = config[CONFIG_KEY_TRICKS]
-        except KeyError:
-            raise KeyError(f"No {CONFIG_KEY_TRICKS!r} key specified in {tricks_file!r}.")
+        except KeyError as e:
+            raise KeyError(f"No {CONFIG_KEY_TRICKS!r} key specified in {tricks_file!r}.") from e
 
         if CONFIG_KEY_PYTHON_PATH in config:
             add_to_sys_path(config[CONFIG_KEY_PYTHON_PATH])
 
-        dir_path = os.path.dirname(tricks_file)
-        if not dir_path:
-            dir_path = os.path.relpath(os.getcwd())
+        dir_path = os.path.dirname(tricks_file) or os.path.relpath(os.getcwd())
         schedule_tricks(observer, tricks, dir_path, args.recursive)
         observer.start()
         observers.append(observer)
@@ -343,9 +334,7 @@ def tricks_from(args):
     cmd_aliases=["generate-tricks-yaml"],
 )
 def tricks_generate_yaml(args):
-    """
-    Command to generate Yaml configuration for tricks named on the command line.
-    """
+    """Command to generate Yaml configuration for tricks named on the command line."""
     import yaml
 
     python_paths = path_split(args.python_path)
@@ -441,12 +430,10 @@ def tricks_generate_yaml(args):
             action="store_true",
             help="[debug] Forces Linux inotify(7).",
         ),
-    ]
+    ],
 )
 def log(args):
-    """
-    Command to log file system events to the console.
-    """
+    """Command to log file system events to the console."""
     from watchdog.tricks import LoggerTrick
     from watchdog.utils import echo
 
@@ -466,7 +453,7 @@ def log(args):
         from watchdog.observers.polling import PollingObserver as Observer
     elif args.debug_force_kqueue:
         from watchdog.observers.kqueue import KqueueObserver as Observer
-    elif (not TYPE_CHECKING and args.debug_force_winapi) or (TYPE_CHECKING and sys.platform.startswith("win")):
+    elif (not TYPE_CHECKING and args.debug_force_winapi) or (TYPE_CHECKING and platform.is_windows()):
         from watchdog.observers.read_directory_changes import WindowsApiObserver as Observer
     elif args.debug_force_inotify:
         from watchdog.observers.inotify import InotifyObserver as Observer
@@ -563,12 +550,10 @@ def log(args):
             " executed to avoid multiple simultaneous instances.",
         ),
         argument("--debug-force-polling", action="store_true", help="[debug] Forces polling."),
-    ]
+    ],
 )
 def shell_command(args):
-    """
-    Command to execute shell commands in response to file system events.
-    """
+    """Command to execute shell commands in response to file system events."""
     from watchdog.tricks import ShellCommandTrick
 
     if not args.command:
@@ -613,7 +598,7 @@ def shell_command(args):
             dest="directories",
             metavar="DIRECTORY",
             action="append",
-            help="Directory to watch. Use another -d or --directory option " "for each directory.",
+            help="Directory to watch. Use another -d or --directory option for each directory.",
         ),
         argument(
             "-p",
@@ -666,7 +651,7 @@ def shell_command(args):
             dest="kill_after",
             default=10.0,
             type=float,
-            help="When stopping, kill the subprocess after the specified timeout " "in seconds (default 10.0).",
+            help="When stopping, kill the subprocess after the specified timeout in seconds (default 10.0).",
         ),
         argument(
             "--debounce-interval",
@@ -683,13 +668,10 @@ def shell_command(args):
             action="store_false",
             help="Don't auto-restart the command after it exits.",
         ),
-    ]
+    ],
 )
 def auto_restart(args):
-    """
-    Command to start a long-running subprocess and restart it on matched events.
-    """
-
+    """Command to start a long-running subprocess and restart it on matched events."""
     Observer: BaseObserverSubclassCallable
     if args.debug_force_polling:
         from watchdog.observers.polling import PollingObserver as Observer
@@ -704,10 +686,7 @@ def auto_restart(args):
         args.directories = ["."]
 
     # Allow either signal name or number.
-    if args.signal.startswith("SIG"):
-        stop_signal = getattr(signal, args.signal)
-    else:
-        stop_signal = int(args.signal)
+    stop_signal = getattr(signal, args.signal) if args.signal.startswith("SIG") else int(args.signal)
 
     # Handle termination signals by raising a semantic exception which will
     # allow us to gracefully unwind and stop the observer
@@ -771,7 +750,7 @@ def main():
     try:
         log_level = _get_log_level_from_args(args)
     except LogLevelException as exc:
-        print(f"Error: {exc.args[0]}", file=sys.stderr)
+        print(f"Error: {exc.args[0]}", file=sys.stderr)  # noqa:T201
         command_parsers[args.top_command].print_help()
         return 1
     logging.getLogger("watchdog").setLevel(log_level)
diff --git a/tox.ini b/tox.ini
index a211e61..1a10f7e 100644
--- a/tox.ini
+++ b/tox.ini
@@ -1,8 +1,10 @@
 [tox]
 envlist =
-    py{311,310,39,38,37,36,py3}
+    py3{8,9,10,11,12}
+    pypy3
     docs
-    mypy
+    types
+    lint
 skip_missing_interpreters = True
 
 [testenv]
@@ -14,15 +16,6 @@ extras =
 commands =
     python -bb -m pytest {posargs}
 
-[testenv:flake8]
-usedevelop = true
-deps =
-    -r requirements-tests.txt
-extras =
-    watchmedo
-commands =
-    python -m flake8 docs tools src tests setup.py
-
 [testenv:docs]
 usedevelop = true
 deps =
@@ -32,22 +25,20 @@ extras =
 commands =
     sphinx-build -aEWb html docs/source docs/build/html
 
-[testenv:mypy]
+[testenv:lint]
 usedevelop = true
 deps =
     -r requirements-tests.txt
+extras =
+    watchmedo
 commands =
-    mypy
+    python -m ruff format src
+    python -m ruff check --fix src
 
-[testenv:isort]
+[testenv:types]
 usedevelop = true
 deps =
     -r requirements-tests.txt
 commands =
-    isort src/watchdog/ tests/ *.py
-
-[testenv:isort-ci]
-usedevelop = {[testenv:isort]usedevelop}
-deps = {[testenv:isort]deps}
-commands =
-    isort --diff --check-only src/watchdog/ tests/ *.py
+    # "--platform win32" to not fail on ctypes.windll (it does not affect the overall check on other OSes)
+    mypy --platform win32 src


Test patch:
diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml
index 6fecdc0..2c572ad 100644
--- a/.github/workflows/tests.yml
+++ b/.github/workflows/tests.yml
@@ -5,8 +5,6 @@ on:
     branches:
       - master
   pull_request:
-    branches:
-      - '**'
 
 concurrency:
   group: ${{ github.ref }}-${{ github.workflow }}-${{ github.event_name }}-${{ github.event_name != 'pull_request' && github.sha || '' }}
@@ -21,12 +19,12 @@ jobs:
       fail-fast: false
       matrix:
         tox:
+          - name: Types
+            environment: types
+            timeout: 15
           - name: Test
             environment: py
             timeout: 15
-          - name: mypy
-            environment: mypy
-            timeout: 15
         os:
           - name: Linux
             matrix: linux
@@ -41,26 +39,17 @@ jobs:
             emoji: 🪟
             runs-on: [windows-latest]
         python:
-          - "3.7"
           - "3.8"
           - "3.9"
           - "3.10"
           - "3.11"
+          - "3.12"
           - "pypy-3.8"
           - "pypy-3.9"
         include:
           - tox:
-              name: Flake8
-              environment: flake8
-              timeout: 5
-            python: "3.11"
-            os:
-              name: Linux
-              emoji: 🐧
-              runs-on: [ubuntu-latest]
-          - tox:
-              name: isort
-              environment: isort-ci
+              name: Linter
+              environment: lint
               timeout: 5
             python: "3.11"
             os:
@@ -77,6 +66,12 @@ jobs:
               emoji: 🐧
               runs-on: [ubuntu-latest]
         exclude:
+          - os:
+              matrix: macos
+            python: "pypy-3.8"
+          - os:
+              matrix: macos
+            python: "pypy-3.9"
           - os:
               matrix: windows
             python: "pypy-3.8"
@@ -87,17 +82,16 @@ jobs:
 
     steps:
       - name: Checkout
-        uses: actions/checkout@v3
+        uses: actions/checkout@v4
 
       - name: Set up Python ${{ matrix.python }}
-        uses: actions/setup-python@v4
+        uses: actions/setup-python@v5
         with:
           python-version: ${{ matrix.python }}
+          cache: pip
 
       - name: Install test dependencies
-        run: |
-          python -m pip install tox
+        run: python -m pip install tox
 
       - name: Run ${{ matrix.tox.name }} in tox
-        run: |
-          python -m tox -e ${{ matrix.tox.environment }}
+        run: python -m tox -e ${{ matrix.tox.environment }}
diff --git a/requirements-tests.txt b/requirements-tests.txt
index 44a39e6..60e375b 100644
--- a/requirements-tests.txt
+++ b/requirements-tests.txt
@@ -1,10 +1,9 @@
 eventlet
-flake8
 flaky
-isort
 pytest
 pytest-cov
 pytest-timeout
+ruff
 sphinx
 mypy
 types-PyYAML
diff --git a/tests/conftest.py b/tests/conftest.py
index b79f7eb..0717e13 100644
--- a/tests/conftest.py
+++ b/tests/conftest.py
@@ -46,6 +46,7 @@ def no_warnings(recwarn):
             "Not importing directory" in message
             or "Using or importing the ABCs" in message
             or "dns.hash module will be removed in future versions" in message
+            or "is still running" in message
             or "eventlet" in filename
         ):
             continue
diff --git a/tests/test_0_watchmedo.py b/tests/test_0_watchmedo.py
index c9fc536..ef815a1 100644
--- a/tests/test_0_watchmedo.py
+++ b/tests/test_0_watchmedo.py
@@ -10,16 +10,15 @@
 # Skip if import PyYAML failed. PyYAML missing possible because
 # watchdog installed without watchmedo. See Installation section
 # in README.rst
-yaml = pytest.importorskip("yaml")  # noqa
+yaml = pytest.importorskip("yaml")
 
+from yaml.constructor import ConstructorError  # noqa: E402
+from yaml.scanner import ScannerError  # noqa: E402
 
-from yaml.constructor import ConstructorError  # noqa
-from yaml.scanner import ScannerError  # noqa
-
-from watchdog import watchmedo  # noqa
-from watchdog.events import FileModifiedEvent, FileOpenedEvent  # noqa
-from watchdog.tricks import AutoRestartTrick, ShellCommandTrick  # noqa
-from watchdog.utils import WatchdogShutdown  # noqa
+from watchdog import watchmedo  # noqa: E402
+from watchdog.events import FileModifiedEvent, FileOpenedEvent  # noqa: E402
+from watchdog.tricks import AutoRestartTrick, ShellCommandTrick  # noqa: E402
+from watchdog.utils import WatchdogShutdown, platform  # noqa: E402
 
 
 def test_load_config_valid(tmpdir):
@@ -149,7 +148,7 @@ def test_auto_restart_on_file_change(tmpdir, capfd):
 
 
 @pytest.mark.xfail(
-    condition=sys.platform.startswith(("win", "darwin")) or sys.implementation.name == "pypy",
+    condition=platform.is_darwin() or platform.is_windows() or sys.implementation.name == "pypy",
     reason="known to be problematic, see #973",
 )
 def test_auto_restart_on_file_change_debounce(tmpdir, capfd):
@@ -174,6 +173,7 @@ def test_auto_restart_on_file_change_debounce(tmpdir, capfd):
     assert trick.restart_count == 2
 
 
+@pytest.mark.flaky(max_runs=5, min_passes=1)
 @pytest.mark.parametrize(
     "restart_on_command_exit",
     [
@@ -181,7 +181,7 @@ def test_auto_restart_on_file_change_debounce(tmpdir, capfd):
         pytest.param(
             False,
             marks=pytest.mark.xfail(
-                condition=sys.platform.startswith(("win", "darwin")),
+                condition=platform.is_darwin() or platform.is_windows(),
                 reason="known to be problematic, see #972",
             ),
         ),
diff --git a/tests/test_fsevents.py b/tests/test_fsevents.py
index 01e9fee..540fb3b 100644
--- a/tests/test_fsevents.py
+++ b/tests/test_fsevents.py
@@ -4,7 +4,7 @@
 
 from watchdog.utils import platform
 
-if not platform.is_darwin():  # noqa
+if not platform.is_darwin():
     pytest.skip("macOS only.", allow_module_level=True)
 
 import logging
@@ -16,7 +16,7 @@
 from time import sleep
 from unittest.mock import patch
 
-import _watchdog_fsevents as _fsevents  # type: ignore[import]
+import _watchdog_fsevents as _fsevents  # type: ignore[import-not-found]
 
 from watchdog.events import FileSystemEventHandler
 from watchdog.observers import Observer
diff --git a/tests/test_inotify_buffer.py b/tests/test_inotify_buffer.py
index 56738b6..8ffe5ee 100644
--- a/tests/test_inotify_buffer.py
+++ b/tests/test_inotify_buffer.py
@@ -18,8 +18,8 @@
 
 from watchdog.utils import platform
 
-if not platform.is_linux():  # noqa
-    pytest.skip("GNU/Linux only.", allow_module_level=True)  # noqa
+if not platform.is_linux():
+    pytest.skip("GNU/Linux only.", allow_module_level=True)
 
 import os
 import random
diff --git a/tests/test_inotify_c.py b/tests/test_inotify_c.py
index f835db7..46f450d 100644
--- a/tests/test_inotify_c.py
+++ b/tests/test_inotify_c.py
@@ -4,7 +4,7 @@
 
 from watchdog.utils import platform
 
-if not platform.is_linux():  # noqa
+if not platform.is_linux():
     pytest.skip("GNU/Linux only.", allow_module_level=True)
 
 import ctypes
diff --git a/tests/test_observers_winapi.py b/tests/test_observers_winapi.py
index f40cda0..d9de163 100644
--- a/tests/test_observers_winapi.py
+++ b/tests/test_observers_winapi.py
@@ -17,7 +17,6 @@
 
 import os
 import os.path
-import sys
 from queue import Empty, Queue
 from time import sleep
 
@@ -25,17 +24,15 @@
 
 from watchdog.events import DirCreatedEvent, DirMovedEvent
 from watchdog.observers.api import ObservedWatch
+from watchdog.utils import platform
 
 from .shell import mkdir, mkdtemp, mv, rm
 
 # make pytest aware this is windows only
-if not sys.platform.startswith("win"):
+if not platform.is_windows():
     pytest.skip("Windows only.", allow_module_level=True)
 
-# make mypy aware this is windows only and provide a clear runtime error just in case
-assert sys.platform.startswith("win"), f"{__name__} requires Windows"
-
-from watchdog.observers.read_directory_changes import WindowsApiEmitter  # noqa: E402
+from watchdog.observers.read_directory_changes import WindowsApiEmitter
 
 SLEEP_TIME = 2
 
diff --git a/tests/test_skip_repeats_queue.py b/tests/test_skip_repeats_queue.py
index 59bff70..8754c9e 100644
--- a/tests/test_skip_repeats_queue.py
+++ b/tests/test_skip_repeats_queue.py
@@ -105,10 +105,6 @@ def test_consecutives_allowed_across_empties():
 
 @cpython_only
 def test_eventlet_monkey_patching():
-    try:
-        import eventlet  # type: ignore[import]
-    except Exception:
-        pytest.skip("eventlet not installed")
-
+    eventlet = pytest.importorskip("eventlet")
     eventlet.monkey_patch()
     basic_actions()
diff --git a/tests/test_snapshot_diff.py b/tests/test_snapshot_diff.py
index 57f4d50..4059f88 100644
--- a/tests/test_snapshot_diff.py
+++ b/tests/test_snapshot_diff.py
@@ -55,6 +55,20 @@ def test_move_to(p):
     assert diff.files_created == [p("dir2", "b")]
 
 
+def test_move_to_with_context_manager(p):
+    mkdir(p("dir1"))
+    touch(p("dir1", "a"))
+    mkdir(p("dir2"))
+
+    dir1_cm = DirectorySnapshotDiff.ContextManager(p("dir1"))
+    dir2_cm = DirectorySnapshotDiff.ContextManager(p("dir2"))
+    with dir1_cm, dir2_cm:
+        mv(p("dir1", "a"), p("dir2", "b"))
+
+    assert dir1_cm.diff.files_deleted == [p("dir1", "a")]
+    assert dir2_cm.diff.files_created == [p("dir2", "b")]
+
+
 def test_move_from(p):
     mkdir(p("dir1"))
     mkdir(p("dir2"))
diff --git a/tests/utils.py b/tests/utils.py
index c70ad91..738bdde 100644
--- a/tests/utils.py
+++ b/tests/utils.py
@@ -2,24 +2,23 @@
 
 import dataclasses
 import os
-import sys
 from queue import Empty, Queue
 from typing import List, Optional, Tuple, Type, Union
 
 from watchdog.events import FileSystemEvent
 from watchdog.observers.api import EventEmitter, ObservedWatch
-from watchdog.utils import Protocol
+from watchdog.utils import Protocol, platform
 
 Emitter: Type[EventEmitter]
 
-if sys.platform.startswith("linux"):
+if platform.is_linux():
     from watchdog.observers.inotify import InotifyEmitter as Emitter
     from watchdog.observers.inotify import InotifyFullEmitter
-elif sys.platform.startswith("darwin"):
+elif platform.is_darwin():
     from watchdog.observers.fsevents import FSEventsEmitter as Emitter
-elif sys.platform.startswith("win"):
+elif platform.is_windows():
     from watchdog.observers.read_directory_changes import WindowsApiEmitter as Emitter
-elif sys.platform.startswith(("dragonfly", "freebsd", "netbsd", "openbsd", "bsd")):
+elif platform.is_bsd():
     from watchdog.observers.kqueue import KqueueEmitter as Emitter
 
 
@@ -65,14 +64,14 @@ def start_watching(
         path = self.tmp if path is None else path
 
         emitter: EventEmitter
-        if sys.platform.startswith("linux") and use_full_emitter:
+        if platform.is_linux() and use_full_emitter:
             emitter = InotifyFullEmitter(self.event_queue, ObservedWatch(path, recursive=recursive))
         else:
             emitter = Emitter(self.event_queue, ObservedWatch(path, recursive=recursive))
 
         self.emitters.append(emitter)
 
-        if sys.platform.startswith("darwin"):
+        if platform.is_darwin():
             # TODO: I think this could be better...  .suppress_history should maybe
             #       become a common attribute.
             from watchdog.observers.fsevents import FSEventsEmitter