diff --git a/.cirrus.yml b/.cirrus.yml index 488ce791..b305a1ad 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 00000000..c78a0857 --- /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 3877d255..de4371f4 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/.github/workflows/tests.yml b/.github/workflows/tests.yml index c482acb2..2c572add 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 @@ -50,17 +48,8 @@ jobs: - "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/.isort.cfg b/.isort.cfg deleted file mode 100644 index 75572027..00000000 --- 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 b817bf33..2d6e0dd4 100755 --- a/README.rst +++ b/README.rst @@ -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 diff --git a/changelog.rst b/changelog.rst index bd180a0b..ff507fef 100644 --- a/changelog.rst +++ b/changelog.rst @@ -3,10 +3,27 @@ Changelog --------- -3.0.1 +4.0.2 (dev) +~~~~~~~~~~~ + +2024-xx-xx ā€¢ `full history `__ + +- [core] Run ``ruff``, apply several fixes (`#1033 `__) +- [fsevents] Add missing ``event_filter`` keyword-argument to ``FSEventsObserver.schedule()`` (`#1049 `__) +- Thanks to our beloved contributors: @BoboTiG + +4.0.1 +~~~~~ + +2024-05-23 ā€¢ `full history `__ + +- [inotify] Fix missing ``event_filter`` for the full emitter (`#1032 `__) +- Thanks to our beloved contributors: @mraspaud, @BoboTiG + +4.0.0 ~~~~~ -2023-xx-xx ā€¢ `full history `__ +2024-02-06 ā€¢ `full history `__ - Drop support for Python 3.7. - Add support for Python 3.12. diff --git a/docs/source/conf.py b/docs/source/conf.py index 49163faf..86e5424f 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 21f02d14..59d46c6d 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/quickstart.rst b/docs/source/quickstart.rst index 9b73500b..d251f3b4 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 d8796ad5..00000000 --- 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 index 9fdacdf5..9be9550e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,7 +1,72 @@ -[tool.black] -target-version = ["py38"] +[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 -safe = true +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.isort] -profile = "black" +[tool.ruff.format] +quote-style = "double" +indent-style = "space" +skip-magic-trailing-comma = false +line-ending = "auto" +docstring-code-format = true diff --git a/requirements-tests.txt b/requirements-tests.txt index 44a39e6e..60e375bf 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/setup.cfg b/setup.cfg index 8c5532bd..61c7f61c 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/src/watchdog/events.py b/src/watchdog/events.py old mode 100755 new mode 100644 index 51c6c5bf..8b8d6348 --- 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 ab92c618..293a6f6b 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 50550f32..4dc7d3c6 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 b3b434b0..55c0ffcd 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-not-found] +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,30 +312,28 @@ 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=True): + def schedule(self, event_handler, path, recursive=True, 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) + if not recursive: import warnings + warnings.warn("FSEvents requires and assumes recursive=True") - return BaseObserver.schedule(self, event_handler, path, recursive=True) + recursive = True + + 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 4b8872e8..71941742 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-not-found] -from FSEvents import ( # type: ignore[import-not-found] +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 fe967302..f45e339c 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 :author: Luke McCarthy @@ -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): @@ -240,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: @@ -254,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 aec29e2a..0935aaa5 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 6b54f5e0..7233af3b 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 af74a6c5..1bea14b6 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) @@ -53,8 +52,7 @@ 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. """ @@ -71,7 +69,10 @@ def __init__( 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 fe038cc2..be84bac1 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 a4956c11..b8576523 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 65bf074a..5395443d 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 aea5f4e0..ece812ff 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 980a0ff6..45a80437 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 f3a5ead6..e6f11836 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 0a67c33d..77f03187 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,6 +47,7 @@ from __future__ import annotations +import contextlib import errno import os from stat import S_ISDIR @@ -55,8 +55,7 @@ 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: @@ -126,9 +125,10 @@ def get_inode(directory: DirectorySnapshot, full_path: str) -> int | Tuple[int, # first check paths that have not moved 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): @@ -181,8 +181,7 @@ def files_modified(self) -> List[str]: @property def files_moved(self) -> list[Tuple[str, str]]: - """ - List of files that were moved. + """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. @@ -191,15 +190,12 @@ def files_moved(self) -> list[Tuple[str, str]]: @property def dirs_modified(self) -> List[str]: - """ - List of directories that were modified. - """ + """List of directories that were modified.""" return self._dirs_modified @property def dirs_moved(self) -> List[tuple[str, str]]: - """ - List of directories that were moved. + """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. @@ -208,21 +204,16 @@ def dirs_moved(self) -> List[tuple[str, str]]: @property def dirs_deleted(self) -> List[str]: - """ - List of directories that were deleted. - """ + """List of directories that were deleted.""" return self._dirs_deleted @property def dirs_created(self) -> List[str]: - """ - List of directories that were created. - """ + """List of directories that were created.""" return self._dirs_created class ContextManager: - """ - Context manager that creates two directory snapshots and a + """Context manager that creates two directory snapshots and a diff object that represents the difference between the two snapshots. :param path: @@ -289,8 +280,7 @@ def get_snapshot(self): 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. @@ -349,34 +339,25 @@ def walk(self, root: str) -> Iterator[Tuple[str, os.stat_result]]: 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[str]: - """ - Set of file/directory paths in the snapshot. - """ + """Set of file/directory paths in the snapshot.""" return set(self._stat_info.keys()) - def path(self, id: Tuple[int, int]) -> Optional[str]: - """ - 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: str) -> Tuple[int, int]: """Returns an id for path.""" @@ -393,8 +374,7 @@ def size(self, path: str) -> int: return self._stat_info[path].st_size def stat_info(self, path: str) -> os.stat_result: - """ - Returns a stat information object for the specified path from + """Returns a stat information object for the specified path from the snapshot. Attached information is subject to change. Do not use unless @@ -440,7 +420,7 @@ def path(_: Any) -> None: :returns: None. """ - return None + return @property def paths(self) -> set: diff --git a/src/watchdog/utils/echo.py b/src/watchdog/utils/echo.py index 2e4ade99..73683c00 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 0785c5cd..f2db3713 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 fefa542b..0f6b05a3 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 dd4ece58..46717bc7 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 b912a8c4..760a7246 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 997de393..783d1e06 --- 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/tests/conftest.py b/tests/conftest.py index b79f7ebc..0717e13a 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 c9fc5369..ef815a1a 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 4814ba86..3a1623d0 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 diff --git a/tests/test_inotify_buffer.py b/tests/test_inotify_buffer.py index 56738b60..8ffe5ee9 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 f835db7c..46f450d2 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 f40cda05..d9de163e 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 d60393e3..8754c9ee 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-untyped] - except Exception: - pytest.skip("eventlet not installed") - + eventlet = pytest.importorskip("eventlet") eventlet.monkey_patch() basic_actions() diff --git a/tests/utils.py b/tests/utils.py index c70ad91b..738bddee 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 diff --git a/tox.ini b/tox.ini index 2f5849ec..1a10f7ee 100644 --- a/tox.ini +++ b/tox.ini @@ -1,8 +1,10 @@ [tox] envlist = - py{312,311,310,39,38,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