Skip to content

Commit

Permalink
Fixes, and clean-up (#1084)
Browse files Browse the repository at this point in the history
* fix: pin tests deps

* fix: Mypy

* feat: skip type checking blocks in coverage

* fix: remove unneeded `echo` functions

* docs: tweak

* fixes

* fix
  • Loading branch information
BoboTiG authored Nov 1, 2024
1 parent db698a5 commit 58386d8
Show file tree
Hide file tree
Showing 14 changed files with 94 additions and 124 deletions.
4 changes: 2 additions & 2 deletions .github/workflows/tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -58,7 +58,7 @@ jobs:
- "3.10"
- "3.11"
- "3.12"
- "3.13-dev"
- "3.13"
- "pypy-3.9"
exclude:
- os:
Expand Down
2 changes: 1 addition & 1 deletion README.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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:

Expand Down
19 changes: 18 additions & 1 deletion changelog.rst
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,25 @@ Changelog

2024-xx-xx • `full history <https://github.com/gorakhargosh/watchdog/compare/v5.0.3...HEAD>`__

- Pin test dependecies.
- [docs] Add typing info to quick start. (`#1082 <https://github.com/gorakhargosh/watchdog/pull/1082>`__)
- [inotify] Use of ``select.poll()`` instead of deprecated ``select.select()``, if available. (`#1078 <https://github.com/gorakhargosh/watchdog/pull/1078>`__)
- Thanks to our beloved contributors: @BoboTiG, @
- [inotify] Fix reading inotify file descriptor after closing it. (`#1081 <https://github.com/gorakhargosh/watchdog/pull/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
~~~~~
Expand Down
6 changes: 4 additions & 2 deletions docs/source/quickstart.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
6 changes: 6 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
@@ -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
Expand Down
19 changes: 10 additions & 9 deletions requirements-tests.txt
Original file line number Diff line number Diff line change
@@ -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
1 change: 1 addition & 0 deletions setup.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
6 changes: 2 additions & 4 deletions src/watchdog/observers/read_directory_changes.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
7 changes: 5 additions & 2 deletions src/watchdog/tricks/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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__}:
Expand Down Expand Up @@ -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,
Expand All @@ -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
Expand Down
100 changes: 6 additions & 94 deletions src/watchdog/utils/echo.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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)
9 changes: 3 additions & 6 deletions src/watchdog/watchmedo.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down Expand Up @@ -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",
Expand All @@ -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(
Expand Down
2 changes: 1 addition & 1 deletion tests/shell.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
35 changes: 34 additions & 1 deletion tests/test_0_watchmedo.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
from __future__ import annotations

import logging
import os
import sys
import time
Expand All @@ -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


Expand Down Expand Up @@ -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=<AutoRestartTrick>, event=FileOpenedEvent(src_path='foo/bar.baz', dest_path='', event_type='opened', is_directory=False, is_synthetic=False))", # noqa: E501
"on_any_event(self=<AutoRestartTrick>, event=FileOpenedEvent(src_path='foo/bar2.baz', dest_path='', event_type='opened', is_directory=False, is_synthetic=False))", # noqa: E501
"on_any_event(self=<AutoRestartTrick>, 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=<LoggerTrick>, event=FileOpenedEvent(src_path='foo/bar.baz', dest_path='', event_type='opened', is_directory=False, is_synthetic=False))", # noqa: E501
"on_any_event(self=<LoggerTrick>, event=FileOpenedEvent(src_path='foo/bar2.baz', dest_path='', event_type='opened', is_directory=False, is_synthetic=False))", # noqa: E501
"on_any_event(self=<LoggerTrick>, 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'"
Expand Down
Loading

0 comments on commit 58386d8

Please sign in to comment.