Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 4 additions & 4 deletions README.rst
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,7 @@ ignoring all directory events:
.. code-block:: bash

watchmedo log \
--patterns='*.py;*.txt' \
--patterns='**/*.py;**/*.txt' \
--ignore-directories \
--recursive \
--verbose \
Expand All @@ -74,7 +74,7 @@ response to events:
.. code-block:: bash

watchmedo shell-command \
--patterns='*.py;*.txt' \
--patterns='**/*.py;**/*.txt' \
--recursive \
--command='echo "${watch_src_path}"' \
.
Expand All @@ -101,9 +101,9 @@ An example ``tricks.yaml`` file:

tricks:
- watchdog.tricks.LoggerTrick:
patterns: ["*.py", "*.js"]
patterns: ["**/*.py", "**/*.js"]
- watchmedo_webtricks.GoogleClosureTrick:
patterns: ['*.js']
patterns: ['**/*.js']
hash_names: true
mappings_format: json # json|yaml|python
mappings_module: app/javascript_mappings
Expand Down
34 changes: 34 additions & 0 deletions THIRD_PARTY_LICENSES.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
# Third Party Licenses

## Python Standard Library Compatibility Code

This project includes the following unmodified functions from the Python 3.13 standard library:

- `glob.translate` (from `Lib/glob.py`)
- `fnmatch._translate` (from `Lib/fnmatch.py`)

These are included in `backwards_compat.py` to provide backwards compatibility with older Python versions.

**License**: Python Software Foundation License Version 2
**Copyright**: © 2001–2024 Python Software Foundation; All Rights Reserved
**Source**: https://github.com/python/cpython

---

### Python Software Foundation License Version 2

1. This LICENSE AGREEMENT is between the Python Software Foundation ("PSF"), and the Individual or Organization ("Licensee") accessing and otherwise using this software ("Python") in source or binary form and its associated documentation.

2. Subject to the terms and conditions of this License Agreement, PSF hereby grants Licensee a nonexclusive, royalty-free, world-wide license to reproduce, analyze, test, perform and/or display publicly, prepare derivative works, distribute, and otherwise use Python alone or in any derivative version, provided, however, that PSF's License Agreement and PSF's notice of copyright, i.e., "Copyright (c) 2001 Python Software Foundation; All Rights Reserved" are retained in Python alone or in any derivative version prepared by Licensee.

3. In the event Licensee prepares a derivative work that is based on or incorporates Python or any part thereof, and wants to make the derivative work available to others as provided herein, then Licensee hereby agrees to include in any such work a brief summary of the changes made to Python.

4. PSF is making Python available to Licensee on an "AS IS" basis. PSF MAKES NO REPRESENTATIONS OR WARRANTIES, EXPRESS OR IMPLIED. BY WAY OF EXAMPLE, BUT NOT LIMITATION, PSF MAKES NO AND DISCLAIMS ANY REPRESENTATION OR WARRANTY OF MERCHANTABILITY OR FITNESS FOR ANY PARTICULAR PURPOSE OR THAT THE USE OF PYTHON WILL NOT INFRINGE ANY THIRD PARTY RIGHTS.

5. PSF SHALL NOT BE LIABLE TO LICENSEE OR ANY OTHER USERS OF PYTHON FOR ANY INCIDENTAL, SPECIAL, OR CONSEQUENTIAL DAMAGES OR LOSS AS A RESULT OF MODIFYING, DISTRIBUTING, OR OTHERWISE USING PYTHON, OR ANY DERIVATIVE THEREOF, EVEN IF ADVISED OF THE POSSIBILITY THEREOF.

6. This License Agreement will automatically terminate upon a material breach of its terms and conditions.

7. Nothing in this License Agreement shall be deemed to create any relationship of agency, partnership, or joint venture between PSF and Licensee. This License Agreement does not grant permission to use PSF trademarks or trade name in a trademark sense to endorse or promote products or services of Licensee, or any third party.

8. By copying, installing or otherwise using Python, Licensee agrees to be bound by the terms and conditions of this License Agreement.
4 changes: 4 additions & 0 deletions changelog.rst
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,10 @@ Changelog
- Adjust ``Observer.schedule()`` ``path`` type annotation to reflect the ``pathlib.Path`` support. (`#1096 <https://github.com/gorakhargosh/watchdog/pull/1096>`__)
- Thanks to our beloved contributors: @BoboTiG, @tybug

**Breaking Changes**

- Fix #798 by changing pattern matching from using `path.match` to `path.full_match` Users must update patterns to glob like syntax. E.g., `*.py` to `**/*.py`.

6.0.0
~~~~~

Expand Down
4 changes: 3 additions & 1 deletion docs/source/examples/patterns.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,9 @@ def on_any_event(self, event: FileSystemEvent) -> None:
logging.debug(event)


event_handler = MyEventHandler(patterns=["*.py", "*.pyc"], ignore_patterns=["version.py"], ignore_directories=True)
event_handler = MyEventHandler(
patterns=["**/*.py", "**/*.pyc"], ignore_patterns=["version.py"], ignore_directories=True
)
observer = Observer()
observer.schedule(event_handler, sys.argv[1], recursive=True)
observer.start()
Expand Down
6 changes: 3 additions & 3 deletions docs/source/examples/tricks.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,8 @@
{
"watchdog.tricks.LoggerTrick": {
"patterns": [
"*.py",
"*.js"
"**/*.py",
"**/*.js"
]
}
},
Expand All @@ -22,7 +22,7 @@
"suffix": ".min.js",
"source_directory": "app/static/js/",
"hash_names": true,
"patterns": ["*.js"],
"patterns": ["**/*.js"],
"destination_directory": "app/public/js/",
"compilation_level": "advanced",
"mappings_module": "app/javascript_mappings.json"
Expand Down
3 changes: 2 additions & 1 deletion src/watchdog/observers/fsevents.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,6 @@
import threading
import time
import unicodedata
from pathlib import Path
from typing import TYPE_CHECKING

import _watchdog_fsevents as _fsevents
Expand All @@ -33,6 +32,8 @@
from watchdog.utils.dirsnapshot import DirectorySnapshot

if TYPE_CHECKING:
from pathlib import Path

from watchdog.events import FileSystemEvent, FileSystemEventHandler
from watchdog.observers.api import EventQueue, ObservedWatch

Expand Down
148 changes: 148 additions & 0 deletions src/watchdog/utils/backwards_compat.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,148 @@
# ruff: noqa
# fmt: off
# type: ignore
"""
This file includes unmodified functions copied from Python 3.13's standard library
for use on older Python versions.
Functions copied:
- glob.translate (from Lib/glob.py)
- fnmatch._translate (from Lib/fnmatch.py)
Source: https://github.com/python/cpython
License: Python Software Foundation License Version 2
Copyright (c) 2001-2024 Python Software Foundation; All Rights Reserved
Please delete me if/when this project releases forcing python >= 3.13
"""

import os
import re


# Copied from python 3.13 fnmatch._translate
def _translate(pat, STAR, QUESTION_MARK):
res = []
add = res.append
i, n = 0, len(pat)
while i < n:
c = pat[i]
i = i+1
if c == '*':
# compress consecutive `*` into one
if (not res) or res[-1] is not STAR:
add(STAR)
elif c == '?':
add(QUESTION_MARK)
elif c == '[':
j = i
if j < n and pat[j] == '!':
j = j+1
if j < n and pat[j] == ']':
j = j+1
while j < n and pat[j] != ']':
j = j+1
if j >= n:
add('\\[')
else:
stuff = pat[i:j]
if '-' not in stuff:
stuff = stuff.replace('\\', r'\\')
else:
chunks = []
k = i+2 if pat[i] == '!' else i+1
while True:
k = pat.find('-', k, j)
if k < 0:
break
chunks.append(pat[i:k])
i = k+1
k = k+3
chunk = pat[i:j]
if chunk:
chunks.append(chunk)
else:
chunks[-1] += '-'
# Remove empty ranges -- invalid in RE.
for k in range(len(chunks)-1, 0, -1):
if chunks[k-1][-1] > chunks[k][0]:
chunks[k-1] = chunks[k-1][:-1] + chunks[k][1:]
del chunks[k]
# Escape backslashes and hyphens for set difference (--).
# Hyphens that create ranges shouldn't be escaped.
stuff = '-'.join(s.replace('\\', r'\\').replace('-', r'\-')
for s in chunks)
# Escape set operations (&&, ~~ and ||).
stuff = re.sub(r'([&~|])', r'\\\1', stuff)
i = j+1
if not stuff:
# Empty range: never match.
add('(?!)')
elif stuff == '!':
# Negated empty range: match any character.
add('.')
else:
if stuff[0] == '!':
stuff = '^' + stuff[1:]
elif stuff[0] in ('^', '['):
stuff = '\\' + stuff
add(f'[{stuff}]')
else:
add(re.escape(c))
assert i == n
return res


def translate(pat, *, recursive=False, include_hidden=False, seps=None):
"""Translate a pathname with shell wildcards to a regular expression.
If `recursive` is true, the pattern segment '**' will match any number of
path segments.
If `include_hidden` is true, wildcards can match path segments beginning
with a dot ('.').
If a sequence of separator characters is given to `seps`, they will be
used to split the pattern into segments and match path separators. If not
given, os.path.sep and os.path.altsep (where available) are used.
"""
if not seps:
if os.path.altsep:
seps = (os.path.sep, os.path.altsep)
else:
seps = os.path.sep
escaped_seps = ''.join(map(re.escape, seps))
any_sep = f'[{escaped_seps}]' if len(seps) > 1 else escaped_seps
not_sep = f'[^{escaped_seps}]'
if include_hidden:
one_last_segment = f'{not_sep}+'
one_segment = f'{one_last_segment}{any_sep}'
any_segments = f'(?:.+{any_sep})?'
any_last_segments = '.*'
else:
one_last_segment = f'[^{escaped_seps}.]{not_sep}*'
one_segment = f'{one_last_segment}{any_sep}'
any_segments = f'(?:{one_segment})*'
any_last_segments = f'{any_segments}(?:{one_last_segment})?'

results = []
parts = re.split(any_sep, pat)
last_part_idx = len(parts) - 1
for idx, part in enumerate(parts):
if part == '*':
results.append(one_segment if idx < last_part_idx else one_last_segment)
elif recursive and part == '**':
if idx < last_part_idx:
if parts[idx + 1] != '**':
results.append(any_segments)
else:
results.append(any_last_segments)
else:
if part:
if not include_hidden and part[0] in '*?':
results.append(r'(?!\.)')
results.extend(_translate(part, f'{not_sep}*', not_sep))
if idx < last_part_idx:
results.append(any_sep)
res = ''.join(results)
return fr'(?s:{res})\Z'
40 changes: 36 additions & 4 deletions src/watchdog/utils/patterns.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,13 +14,43 @@
# - `PureWindowsPath` is always case-insensitive.
# - `PurePosixPath` is always case-sensitive.
# Reference: https://docs.python.org/3/library/pathlib.html#pathlib.PurePath.match
from pathlib import PurePosixPath, PureWindowsPath
import re
from pathlib import PurePath, PurePosixPath, PureWindowsPath
from typing import TYPE_CHECKING

from watchdog.utils.backwards_compat import translate # type: ignore[attr-defined]

if TYPE_CHECKING:
from collections.abc import Iterator


def _get_sep(path: PurePath) -> str:
"""
Python < 3.13 doesn't have a clean way to expose the path separator
It's either this, or make use of `path._flavour.sep`
"""
if isinstance(path, PureWindowsPath):
return "\\"
if isinstance(path, PurePosixPath):
return "/"
raise TypeError("Unsupported")


def _full_match(path: PurePath, pattern: str) -> bool:
try:
return path.full_match(pattern)
except AttributeError:
# Replicate for python <3.13
# Please remove this, backwards_compat.py, and python license attributions
# if/when we can pin a release to python >= 3.13
# Construct a pathlib object using the same class as the path to get the
# same pattern path separater when constructing the regex
normalized_pattern = str(type(path)(pattern))
regex = translate(normalized_pattern, recursive=True, include_hidden=True, seps=_get_sep(path))
reobj = re.compile(regex)
return bool(reobj.match(str(path)))


def _match_path(
raw_path: str,
included_patterns: set[str],
Expand All @@ -42,7 +72,9 @@ def _match_path(
error = f"conflicting patterns `{common_patterns}` included and excluded"
raise ValueError(error)

return any(path.match(p) for p in included_patterns) and not any(path.match(p) for p in excluded_patterns)
return any(_full_match(path, p) for p in included_patterns) and not any(
_full_match(path, p) for p in excluded_patterns
)


def filter_paths(
Expand All @@ -59,7 +91,7 @@ def filter_paths(
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,
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.
Expand All @@ -70,7 +102,7 @@ def filter_paths(
A list of pathnames that matched the allowable patterns and passed
through the ignored patterns.
"""
included = set(["*"] if included_patterns is None else included_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:
Expand Down
2 changes: 1 addition & 1 deletion tests/test_0_watchmedo.py
Original file line number Diff line number Diff line change
Expand Up @@ -324,7 +324,7 @@ def test_tricks_from_file(command, tmp_path):
"""
tricks:
- watchdog.tricks.LoggerTrick:
patterns: ["*.py", "*.js"]
patterns: ["**/*.py", "**/*.js"]
"""
)
args = watchmedo.cli.parse_args([command, str(tricks_file)])
Expand Down
2 changes: 1 addition & 1 deletion tests/test_fsevents.py
Original file line number Diff line number Diff line change
Expand Up @@ -273,7 +273,7 @@ def done(self):

cwd = os.getcwd()
os.chdir(p())
event_handler = TestEventHandler(patterns=["*.json"], ignore_patterns=[], ignore_directories=True)
event_handler = TestEventHandler(patterns=["**/*.json"], ignore_patterns=[], ignore_directories=True)
observer = Observer()
observer.schedule(event_handler, ".")
observer.start()
Expand Down
12 changes: 6 additions & 6 deletions tests/test_pattern_matching_event_handler.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,25 +19,25 @@

path_1 = "/path/xyz"
path_2 = "/path/abc"
g_allowed_patterns = ["*.py", "*.txt"]
g_ignore_patterns = ["*.foo"]
g_allowed_patterns = ["**/*.py", "**/*.txt"]
g_ignore_patterns = ["**/*.foo"]


def assert_patterns(event):
paths = [event.src_path, event.dest_path] if hasattr(event, "dest_path") else [event.src_path]
filtered_paths = filter_paths(
paths,
included_patterns=["*.py", "*.txt"],
excluded_patterns=["*.pyc"],
included_patterns=["**/*.py", "**/*.txt"],
excluded_patterns=["**/*.pyc"],
case_sensitive=False,
)
assert filtered_paths


def test_dispatch():
# Utilities.
patterns = ["*.py", "*.txt"]
ignore_patterns = ["*.pyc"]
patterns = ["**/*.py", "**/*.txt"]
ignore_patterns = ["**/*.pyc"]

dir_del_event_match = DirDeletedEvent("/path/blah.py")
dir_del_event_not_match = DirDeletedEvent("/path/foobar")
Expand Down
Loading
Loading