diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 27a01252..e20a4dbd 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -21,7 +21,7 @@ jobs: - name: Set up Python uses: actions/setup-python@v5 with: - python-version: "3.11" + python-version: "3.13" cache: pip - name: Install dependencies @@ -58,7 +58,7 @@ jobs: - "3.10" - "3.11" - "3.12" - - "3.13-dev" + - "3.13" - "pypy-3.9" exclude: - os: diff --git a/README.rst b/README.rst index 8bf98d61..79e54098 100755 --- a/README.rst +++ b/README.rst @@ -128,7 +128,7 @@ Install from PyPI using ``pip``: $ python -m pip install -U watchdog # or to install the watchmedo utility: - $ python -m pip install -U "watchdog[watchmedo]" + $ python -m pip install -U 'watchdog[watchmedo]' Install from source: diff --git a/changelog.rst b/changelog.rst index 61173137..9760d04a 100644 --- a/changelog.rst +++ b/changelog.rst @@ -8,8 +8,25 @@ Changelog 2024-xx-xx • `full history `__ +- Pin test dependecies. +- [docs] Add typing info to quick start. (`#1082 `__) - [inotify] Use of ``select.poll()`` instead of deprecated ``select.select()``, if available. (`#1078 `__) -- Thanks to our beloved contributors: @BoboTiG, @ +- [inotify] Fix reading inotify file descriptor after closing it. (`#1081 `__) +- [utils] The ``stop_signal`` keyword-argument type of the ``AutoRestartTrick`` class can now be either a ``signal.Signals`` or an ``int``. +- [utils] Added the ``__repr__()`` method to the ``Trick`` class. +- [utils] Removed the unused ``echo_class()`` function from the ``echo`` module. +- [utils] Removed the unused ``echo_instancemethod()`` function from the ``echo`` module. +- [utils] Removed the unused ``echo_module()`` function from the ``echo`` module. +- [utils] Removed the unused ``is_class_private_name()`` function from the ``echo`` module. +- [utils] Removed the unused ``is_classmethod()`` function from the ``echo`` module. +- [utils] Removed the unused ``ic_method(met()`` function from the ``echo`` module. +- [utils] Removed the unused ``method_name()`` function from the ``echo`` module. +- [utils] Removed the unused ``name()`` function from the ``echo`` module. +- [watchmedo] Fixed Mypy issues. +- [watchmedo] Added the ``__repr__()`` method to the ``HelpFormatter`` class. +- [watchmedo] Removed the ``--trace`` CLI argument from the ``watchmedo log`` command, useless since events are logged by default at the ``LoggerTrick`` class level. +- [windows] Fixed Mypy issues. +- Thanks to our beloved contributors: @BoboTiG, @g-pichlern, @ethan-vanderheijden, @nhairs 5.0.3 ~~~~~ diff --git a/docs/source/quickstart.rst b/docs/source/quickstart.rst index 02e4ce9b..8669b595 100644 --- a/docs/source/quickstart.rst +++ b/docs/source/quickstart.rst @@ -55,16 +55,18 @@ To stop the program, press Control-C. Typing ------ + If you are using type annotations it is important to note that -`watchdog.observers.Observer` is not actually a class; it is a variable that +:class:`watchdog.observers.Observer` is not actually a class; it is a variable that hold the "best" observer class available on your platform. In order to correctly type your own code your should use -`watchdog.observers.api.BaseObserver`. For example: +:class:`watchdog.observers.api.BaseObserver`. For example:: from watchdog.observers import Observer from watchdog.observers.api import BaseObserver + def my_func(obs: BaseObserver) -> None: # Do something with obs pass diff --git a/pyproject.toml b/pyproject.toml index e9043448..b12c23cf 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,3 +1,9 @@ +[tool.coverage.report] +exclude_also = [ + "if TYPE_CHECKING:", + "if __name__ == __main__:", +] + [tool.mypy] # Ensure we know what we do warn_redundant_casts = true diff --git a/requirements-tests.txt b/requirements-tests.txt index 66d0b58b..1439205e 100644 --- a/requirements-tests.txt +++ b/requirements-tests.txt @@ -1,9 +1,10 @@ -eventlet; python_version < "3.13" -flaky -pytest -pytest-cov -pytest-timeout -ruff -sphinx -mypy -types-PyYAML +eventlet==0.37.0; python_version < "3.13" +flaky==3.8.1 +pytest==8.3.3 +pytest-cov==6.0.0 +pytest-timeout==2.3.1 +ruff==0.7.1 +sphinx==7.4.7; python_version <= "3.9" +sphinx==8.1.3; python_version > "3.9" +mypy==1.13.0 +types-PyYAML==6.0.12.20240917 diff --git a/setup.py b/setup.py index 951c8bbe..47537d95 100644 --- a/setup.py +++ b/setup.py @@ -119,6 +119,7 @@ "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", "Programming Language :: Python :: 3.13", + "Programming Language :: Python :: Implementation :: CPython", "Programming Language :: Python :: Implementation :: PyPy", "Programming Language :: C", "Topic :: Software Development :: Libraries", diff --git a/src/watchdog/observers/read_directory_changes.py b/src/watchdog/observers/read_directory_changes.py index 3b816353..4faa9450 100644 --- a/src/watchdog/observers/read_directory_changes.py +++ b/src/watchdog/observers/read_directory_changes.py @@ -86,12 +86,10 @@ def queue_events(self, timeout: float) -> None: else: self.queue_event(FileMovedEvent(src_path, dest_path)) elif winapi_event.is_modified: - cls = DirModifiedEvent if os.path.isdir(src_path) else FileModifiedEvent - self.queue_event(cls(src_path)) + self.queue_event((DirModifiedEvent if os.path.isdir(src_path) else FileModifiedEvent)(src_path)) elif winapi_event.is_added: isdir = os.path.isdir(src_path) - cls = DirCreatedEvent if isdir else FileCreatedEvent - self.queue_event(cls(src_path)) + self.queue_event((DirCreatedEvent if isdir else FileCreatedEvent)(src_path)) if isdir and self.watch.is_recursive: for sub_created_event in generate_sub_created_events(src_path): self.queue_event(sub_created_event) diff --git a/src/watchdog/tricks/__init__.py b/src/watchdog/tricks/__init__.py index 0a8fc49c..b993b6d2 100644 --- a/src/watchdog/tricks/__init__.py +++ b/src/watchdog/tricks/__init__.py @@ -46,6 +46,9 @@ class Trick(PatternMatchingEventHandler): """Your tricks should subclass this class.""" + def __repr__(self) -> str: + return f"<{type(self).__name__}>" + @classmethod def generate_yaml(cls) -> str: return f"""- {cls.__module__}.{cls.__name__}: @@ -158,7 +161,7 @@ def __init__( patterns: list[str] | None = None, ignore_patterns: list[str] | None = None, ignore_directories: bool = False, - stop_signal: signal.Signals = signal.SIGINT, + stop_signal: signal.Signals | int = signal.SIGINT, kill_after: int = 10, debounce_interval_seconds: int = 0, restart_on_command_exit: bool = True, @@ -177,7 +180,7 @@ def __init__( ) self.command = command - self.stop_signal = stop_signal + self.stop_signal = stop_signal.value if isinstance(stop_signal, signal.Signals) else stop_signal self.kill_after = kill_after self.debounce_interval_seconds = debounce_interval_seconds self.restart_on_command_exit = restart_on_command_exit diff --git a/src/watchdog/utils/echo.py b/src/watchdog/utils/echo.py index 86cc58fe..4ff9217d 100644 --- a/src/watchdog/utils/echo.py +++ b/src/watchdog/utils/echo.py @@ -5,81 +5,34 @@ # # Place into the public domain. -"""Echo calls made to functions and methods in a module. +"""Echo calls made to functions 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 commonly referred to as "tracing", but Python already has a trace module). -Example: to echo calls made to functions in "my_module" do: - - import echo - import my_module - echo.echo_module(my_module) - -Example: to echo calls made to functions in "my_module.my_class" do: - - echo.echo_class(my_module.my_class) - Alternatively, echo.echo can be used to decorate functions. Calls to the decorated function will be echoed. Example: ------- - @echo.echo - def my_function(args): - pass + @echo.echo + def my_function(args): + pass """ from __future__ import annotations -import inspect +import functools import sys from typing import TYPE_CHECKING if TYPE_CHECKING: - from types import MethodType from typing import Any, Callable -def name(item: Callable) -> str: - """Return an item's name.""" - return item.__name__ - - -def is_classmethod(instancemethod: MethodType, klass: type) -> bool: - """Determine if an instancemethod is a classmethod.""" - return inspect.ismethod(instancemethod) and instancemethod.__self__ is klass - - -def is_static_method(method: MethodType, klass: type) -> bool: - """Returns True if method is an instance method of klass.""" - return next( - (isinstance(c.__dict__[name(method)], staticmethod) for c in klass.mro() if name(method) in c.__dict__), - False, - ) - - -def is_class_private_name(name: str) -> bool: - """Determine if a name is a class private name.""" - # Exclude system defined names such as __init__, __add__ etc - return name.startswith("__") and not name.endswith("__") - - -def method_name(method: MethodType) -> str: - """Return a method's name. - - This function returns the name the method is accessed by from - outside the class (i.e. it prefixes "private" methods appropriately). - """ - mname = name(method) - if is_class_private_name(mname): - mname = f"_{name(method.__self__.__class__)}{mname}" - return mname - - def format_arg_value(arg_val: tuple[str, tuple[Any, ...]]) -> str: """Return a string representing a (name, value) pair.""" arg, val = arg_val @@ -93,8 +46,6 @@ def echo(fn: Callable, write: Callable[[str], int | None] = sys.stdout.write) -> made to it by writing out the function's name and the arguments it was called with. """ - import functools - # Unpack function's arg count, arg names, arg defaults code = fn.__code__ argcount = code.co_argcount @@ -111,46 +62,7 @@ def wrapped(*v: Any, **k: Any) -> Callable: nameless = list(map(repr, v[argcount:])) keyword = list(map(format_arg_value, list(k.items()))) args = positional + defaulted + nameless + keyword - write(f"{name(fn)}({', '.join(args)})\n") + write(f"{fn.__name__}({', '.join(args)})\n") return fn(*v, **k) return wrapped - - -def echo_instancemethod(klass: type, method: MethodType, write: Callable[[str], int | None] = sys.stdout.write) -> None: - """Change an instancemethod so that calls to it are echoed. - - Replacing a classmethod is a little more tricky. - See: http://www.python.org/doc/current/ref/types.html - """ - mname = method_name(method) - - # Avoid recursion printing method calls - if mname in {"__str__", "__repr__"}: - return - - if is_classmethod(method, klass): - setattr(klass, mname, classmethod(echo(method.__func__, write))) - else: - setattr(klass, mname, echo(method, write)) - - -def echo_class(klass: type, write: Callable[[str], int | None] = sys.stdout.write) -> None: - """Echo calls to class methods and static functions""" - for _, method in inspect.getmembers(klass, inspect.ismethod): - # In python 3 only class methods are returned here - echo_instancemethod(klass, method, write) - for _, fn in inspect.getmembers(klass, inspect.isfunction): - if is_static_method(fn, klass): - setattr(klass, name(fn), staticmethod(echo(fn, write))) - else: - # It's not a class or a static method, so it must be an instance method. - echo_instancemethod(klass, fn, write) - - -def echo_module(mod: MethodType, write: Callable[[str], int | None] = sys.stdout.write) -> None: - """Echo calls to functions and methods in a module.""" - for fname, fn in inspect.getmembers(mod, inspect.isfunction): - setattr(mod, fname, echo(fn, write)) - for _, klass in inspect.getmembers(mod, inspect.isclass): - echo_class(klass, write) diff --git a/src/watchdog/watchmedo.py b/src/watchdog/watchmedo.py index a4af65e3..e7879563 100644 --- a/src/watchdog/watchmedo.py +++ b/src/watchdog/watchmedo.py @@ -50,6 +50,9 @@ def __init__(self, *args: Any, max_help_position: int = 6, **kwargs: Any) -> Non kwargs["max_help_position"] = max_help_position super().__init__(*args, **kwargs) + def __repr__(self) -> str: + return f"<{type(self).__name__}>" + def _split_lines(self, text: str, width: int) -> list[str]: text = dedent(text).strip() + "\n\n" return text.splitlines() @@ -428,7 +431,6 @@ def tricks_generate_yaml(args: Namespace) -> None: type=float, help="Use this as the polling interval/blocking timeout.", ), - argument("--trace", action="store_true", help="Dumps complete dispatching trace."), argument("--debug-force-polling", action="store_true", help="[debug] Forces polling."), argument( "--debug-force-kqueue", @@ -455,11 +457,6 @@ def tricks_generate_yaml(args: Namespace) -> None: def log(args: Namespace) -> None: """Command to log file system events to the console.""" from watchdog.tricks import LoggerTrick - from watchdog.utils import echo - - if args.trace: - class_module_logger = logging.getLogger(LoggerTrick.__module__) - echo.echo_class(LoggerTrick, write=lambda msg: class_module_logger.info(msg)) patterns, ignore_patterns = parse_patterns(args.patterns, args.ignore_patterns) handler = LoggerTrick( diff --git a/tests/shell.py b/tests/shell.py index 1ce864a8..ec6d7156 100644 --- a/tests/shell.py +++ b/tests/shell.py @@ -36,7 +36,7 @@ def mkdir(path, *, parents=False): try: os.makedirs(path) except OSError as e: - if not e.errno == errno.EEXIST: + if e.errno != errno.EEXIST: raise else: os.mkdir(path) diff --git a/tests/test_0_watchmedo.py b/tests/test_0_watchmedo.py index 4ef36307..c1abcd60 100644 --- a/tests/test_0_watchmedo.py +++ b/tests/test_0_watchmedo.py @@ -1,5 +1,6 @@ from __future__ import annotations +import logging import os import sys import time @@ -17,7 +18,7 @@ from watchdog import watchmedo # noqa: E402 from watchdog.events import FileModifiedEvent, FileOpenedEvent # noqa: E402 -from watchdog.tricks import AutoRestartTrick, ShellCommandTrick # noqa: E402 +from watchdog.tricks import AutoRestartTrick, LoggerTrick, ShellCommandTrick # noqa: E402 from watchdog.utils import WatchdogShutdownError, platform # noqa: E402 @@ -233,6 +234,38 @@ def test_auto_restart_arg_parsing(): assert args.debounce_interval == pytest.approx(0.2) +def test_auto_restart_events_echoed(tmpdir, caplog): + script = make_dummy_script(tmpdir, n=2) + + with caplog.at_level(logging.INFO): + trick = AutoRestartTrick([sys.executable, script]) + trick.on_any_event(FileOpenedEvent("foo/bar.baz")) + trick.on_any_event(FileOpenedEvent("foo/bar2.baz")) + trick.on_any_event(FileOpenedEvent("foo/bar3.baz")) + + records = [record.getMessage().strip() for record in caplog.get_records(when="call")] + assert records == [ + "on_any_event(self=, event=FileOpenedEvent(src_path='foo/bar.baz', dest_path='', event_type='opened', is_directory=False, is_synthetic=False))", # noqa: E501 + "on_any_event(self=, event=FileOpenedEvent(src_path='foo/bar2.baz', dest_path='', event_type='opened', is_directory=False, is_synthetic=False))", # noqa: E501 + "on_any_event(self=, event=FileOpenedEvent(src_path='foo/bar3.baz', dest_path='', event_type='opened', is_directory=False, is_synthetic=False))", # noqa: E501 + ] + + +def test_logger_events_echoed(caplog): + with caplog.at_level(logging.INFO): + trick = LoggerTrick() + trick.on_any_event(FileOpenedEvent("foo/bar.baz")) + trick.on_any_event(FileOpenedEvent("foo/bar2.baz")) + trick.on_any_event(FileOpenedEvent("foo/bar3.baz")) + + records = [record.getMessage().strip() for record in caplog.get_records(when="call")] + assert records == [ + "on_any_event(self=, event=FileOpenedEvent(src_path='foo/bar.baz', dest_path='', event_type='opened', is_directory=False, is_synthetic=False))", # noqa: E501 + "on_any_event(self=, event=FileOpenedEvent(src_path='foo/bar2.baz', dest_path='', event_type='opened', is_directory=False, is_synthetic=False))", # noqa: E501 + "on_any_event(self=, event=FileOpenedEvent(src_path='foo/bar3.baz', dest_path='', event_type='opened', is_directory=False, is_synthetic=False))", # noqa: E501 + ] + + def test_shell_command_arg_parsing(): args = watchmedo.cli.parse_args(["shell-command", "--command='cmd'"]) assert args.command == "'cmd'" diff --git a/tox.ini b/tox.ini index 5d49cd18..4aea83a0 100644 --- a/tox.ini +++ b/tox.ini @@ -14,7 +14,7 @@ deps = extras = watchmedo commands = - python -bb -m pytest {posargs} + python -m pytest {posargs} [testenv:docs] usedevelop = true