├── .flake8 ├── .github └── workflows │ ├── check.yml │ └── test.yml ├── .gitignore ├── CHANGES ├── CREDITS ├── INSTALL ├── LICENSE ├── MANIFEST.in ├── Makefile ├── README.rst ├── aionotify ├── __init__.py ├── aioutils.py ├── base.py └── enums.py ├── examples └── print.py ├── setup.cfg ├── setup.py ├── tests ├── __init__.py ├── test_enums.py ├── test_usage.py └── testevents │ └── .keep_dir └── tox.ini /.flake8: -------------------------------------------------------------------------------- 1 | [flake8] 2 | 3 | max-line-length = 120 4 | 5 | 6 | ; vim:set ft=dosini: 7 | -------------------------------------------------------------------------------- /.github/workflows/check.yml: -------------------------------------------------------------------------------- 1 | name: Check 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | pull_request: 8 | branches: 9 | - master 10 | 11 | jobs: 12 | build: 13 | name: ${{ matrix.tox-environment }} 14 | runs-on: ubuntu-latest 15 | 16 | strategy: 17 | fail-fast: false 18 | matrix: 19 | tox-environment: 20 | - lint 21 | 22 | env: 23 | TOXENV: ${{ matrix.tox-environment }} 24 | 25 | steps: 26 | - uses: actions/checkout@v3 27 | 28 | - name: Set up Python 29 | uses: actions/setup-python@v3 30 | 31 | - name: Install dependencies 32 | run: python -m pip install tox 33 | 34 | - name: Run 35 | run: tox 36 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: Test 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | pull_request: 8 | branches: 9 | - master 10 | 11 | jobs: 12 | build: 13 | name: Python ${{ matrix.python-version }} 14 | runs-on: ubuntu-latest 15 | 16 | strategy: 17 | fail-fast: false 18 | matrix: 19 | include: 20 | - python-version: "3.8" 21 | - python-version: "3.9" 22 | - python-version: "3.10" 23 | - python-version: "3.11" 24 | - python-version: "3.12" 25 | 26 | env: 27 | TOXENV: python-${{ matrix.python-version }} 28 | 29 | steps: 30 | - uses: actions/checkout@v3 31 | 32 | - name: Set up Python ${{ matrix.python-version }} 33 | uses: actions/setup-python@v3 34 | with: 35 | python-version: ${{ matrix.python-version }} 36 | 37 | - name: Install dependencies 38 | run: python -m pip install tox 39 | 40 | - name: Run tests 41 | run: tox 42 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Temporary files 2 | .*.swp 3 | *.pyc 4 | *.pyo 5 | 6 | # Build-related files 7 | docs/_build/ 8 | .coverage 9 | .tox 10 | *.egg-info 11 | *.egg 12 | .eggs/ 13 | build/ 14 | dist/ 15 | htmlcov/ 16 | MANIFEST 17 | -------------------------------------------------------------------------------- /CHANGES: -------------------------------------------------------------------------------- 1 | ChangeLog 2 | ========= 3 | 4 | 0.3.2 (unreleased) 5 | ------------------ 6 | 7 | - Nothing changed yet. 8 | 9 | 10 | 0.3.1 (2024-05-15) 11 | ------------------ 12 | 13 | *Bugfix:* 14 | 15 | - Fix calling ``setup()`` without providing a loop 16 | 17 | 18 | 0.3.0 (2024-02-02) 19 | ------------------ 20 | 21 | *New:* 22 | 23 | - Add support for Python 3.8+ 24 | - Drop support for Python <3.7 25 | 26 | .. _v0.2.0: 27 | 28 | 0.2.0 (2016-04-04) 29 | ------------------ 30 | 31 | *Bugfix:* 32 | 33 | - Add support for disconnecting watches 34 | - Fix concurrent events / watch disconnection 35 | 36 | 37 | .. _v0.1.0: 38 | 39 | 0.1.0 (2016-04-04) 40 | ------------------ 41 | 42 | *New:* 43 | 44 | - Initial version 45 | 46 | .. vim:set ft=rst: 47 | -------------------------------------------------------------------------------- /CREDITS: -------------------------------------------------------------------------------- 1 | Credits 2 | ======= 3 | 4 | 5 | Maintainers 6 | ----------- 7 | 8 | The ``aionotify`` project is operated and maintained by: 9 | 10 | * Raphaël Barrois (https://github.com/rbarrois) 11 | 12 | 13 | .. _contributors: 14 | 15 | Contributors 16 | ------------ 17 | 18 | The project has received contributions from (in alphabetical order): 19 | 20 | * Raphaël Barrois (https://github.com/rbarrois) 21 | 22 | 23 | Contributor license agreement 24 | ----------------------------- 25 | 26 | .. note:: This agreement is required to allow redistribution of submitted contributions. 27 | See http://oss-watch.ac.uk/resources/cla for an explanation. 28 | 29 | Any contributor proposing updates to the code or documentation of this project *MUST* 30 | add its name to the list in the :ref:`contributors` section, thereby "signing" the 31 | following contributor license agreement: 32 | 33 | They accept and agree to the following terms for their present end future contributions 34 | submitted to the ``aionotify`` project: 35 | 36 | * They represent that they are legally entitled to grant this license, and that their 37 | contributions are their original creation 38 | 39 | * They grant the ``aionotify`` project a perpetual, worldwide, non-exclusive, 40 | no-charge, royalty-free, irrevocable copyright license to reproduce, 41 | prepare derivative works of, publicly display, sublicense and distribute their contributions 42 | and such derivative works. 43 | 44 | * They are not expected to provide support for their contributions, except to the extent they 45 | desire to provide support. 46 | 47 | 48 | .. note:: The above agreement is inspired by the Apache Contributor License Agreement. 49 | 50 | .. vim:set ft=rst: 51 | -------------------------------------------------------------------------------- /INSTALL: -------------------------------------------------------------------------------- 1 | Installing aionotify 2 | ==================== 3 | 4 | Prerequisites: 5 | 6 | * Python>=3.4 7 | 8 | 9 | Setup:: 10 | 11 | pip install aionotify 12 | 13 | .. vim:set ft=rst: 14 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) The aionotify project 2 | All rights reserved. 3 | 4 | Redistribution and use in source and binary forms, with or without 5 | modification, are permitted provided that the following conditions are met: 6 | 7 | 1. Redistributions of source code must retain the above copyright notice, this 8 | list of conditions and the following disclaimer. 9 | 2. Redistributions in binary form must reproduce the above copyright notice, 10 | this list of conditions and the following disclaimer in the documentation 11 | and/or other materials provided with the distribution. 12 | 13 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND 14 | ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED 15 | WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 16 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR 17 | ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES 18 | (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; 19 | LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND 20 | ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT 21 | (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS 22 | SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 23 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include CHANGES CREDITS INSTALL LICENSE README.rst 2 | include Makefile tox.ini .flake8 3 | 4 | graft aionotify 5 | 6 | graft examples 7 | graft tests 8 | 9 | global-exclude *.py[cod] __pycache__ .*.sw[po] 10 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | PACKAGE = aionotify 2 | CODE_DIRS = aionotify/ tests/ examples/ 3 | 4 | FLAKE8 = flake8 5 | 6 | 7 | default: test 8 | 9 | .PHONY: default 10 | 11 | # Package management 12 | # ================== 13 | 14 | 15 | clean: 16 | find . -type f -name '*.pyc' -delete 17 | find . -type f -path '*/__pycache__/*' -delete 18 | find . -type d -empty -delete 19 | @rm -rf tmp_test/ 20 | 21 | 22 | update: 23 | pip install --upgrade pip setuptools 24 | pip install --upgrade -e .[dev] 25 | pip freeze 26 | 27 | 28 | release: 29 | fullrelease 30 | 31 | .PHONY: clean update release 32 | 33 | # Tests and quality 34 | # ================= 35 | 36 | 37 | # DOC: Run tests for all supported versions (creates a set of virtualenvs) 38 | testall: 39 | tox 40 | 41 | 42 | # DOC: Run tests for the currently installed version 43 | test: 44 | python -Wdefault -m unittest 45 | 46 | # DOC: Perform code quality tasks 47 | lint: check-manifest flake8 48 | 49 | # DOC: Verify that MANIFEST.in and .gitignore are consistent 50 | check-manifest: 51 | check-manifest 52 | 53 | # Note: we run the linter in two runs, because our __init__.py files has specific warnings we want to exclude 54 | # DOC: Verify code quality 55 | flake8: 56 | $(FLAKE8) $(CODE_DIRS) setup.py 57 | 58 | .PHONY: testall test lint check-manifest flake8 59 | 60 | 61 | # Documentation 62 | # ============= 63 | 64 | 65 | # DOC: Show this help message 66 | help: 67 | @grep -A1 '^# DOC:' Makefile \ 68 | | awk ' \ 69 | BEGIN { FS="\n"; RS="--\n"; opt_len=0; } \ 70 | { \ 71 | doc=$$1; name=$$2; \ 72 | sub("# DOC: ", "", doc); \ 73 | sub(":", "", name); \ 74 | if (length(name) > opt_len) { \ 75 | opt_len = length(name) \ 76 | } \ 77 | opts[NR] = name; \ 78 | docs[name] = doc; \ 79 | } \ 80 | END { \ 81 | pat="%-" (opt_len + 4) "s %s\n"; \ 82 | asort(opts); \ 83 | for (i in opts) { \ 84 | opt=opts[i]; \ 85 | printf pat, opt, docs[opt] \ 86 | } \ 87 | }' 88 | 89 | .PHONY: doc help 90 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | aionotify 2 | ========= 3 | 4 | .. image:: https://img.shields.io/pypi/v/aionotify.svg 5 | :target: https://pypi.python.org/pypi/aionotify/ 6 | :alt: Latest Version 7 | 8 | .. image:: https://img.shields.io/pypi/pyversions/aionotify.svg 9 | :target: https://pypi.python.org/pypi/aionotify/ 10 | :alt: Supported Python versions 11 | 12 | .. image:: https://img.shields.io/pypi/wheel/aionotify.svg 13 | :target: https://pypi.python.org/pypi/aionotify/ 14 | :alt: Wheel status 15 | 16 | .. image:: https://img.shields.io/pypi/l/aionotify.svg 17 | :target: https://pypi.python.org/pypi/aionotify/ 18 | :alt: License 19 | 20 | 21 | ``aionotify`` is a simple, asyncio-based inotify library. 22 | 23 | 24 | Its use is quite simple: 25 | 26 | .. code-block:: python 27 | 28 | import asyncio 29 | import aionotify 30 | 31 | # Setup the watcher 32 | watcher = aionotify.Watcher() 33 | watcher.watch(alias='logs', path='/var/log', flags=aionotify.Flags.MODIFY) 34 | 35 | async def work(): 36 | await watcher.setup() 37 | for _i in range(10): 38 | # Pick the 10 first events 39 | event = await watcher.get_event() 40 | print(event) 41 | watcher.close() 42 | 43 | asyncio.run(work()) 44 | 45 | 46 | Links 47 | ----- 48 | 49 | * Code at https://github.com/rbarrois/aionotify 50 | * Package at https://pypi.python.org/pypi/aionotify/ 51 | * Continuous integration at https://travis-ci.org/rbarrois/aionotify/ 52 | 53 | 54 | Events 55 | ------ 56 | 57 | An event is a simple object with a few attributes: 58 | 59 | * ``name``: the path of the modified file 60 | * ``flags``: the modification flag; use ``aionotify.Flags.parse()`` to retrieve a list of individual values. 61 | * ``alias``: the alias of the watch triggering the event 62 | * ``cookie``: for renames, this integer value links the "renamed from" and "renamed to" events. 63 | 64 | 65 | Watches 66 | ------- 67 | 68 | ``aionotify`` uses a system of "watches", similar to inotify. 69 | 70 | A watch may have an alias; by default, it uses the path name: 71 | 72 | .. code-block:: python 73 | 74 | watcher = aionotify.Watcher() 75 | watcher.watch('/var/log', flags=aionotify.Flags.MODIFY) 76 | 77 | # Similar to: 78 | watcher.watch('/var/log', flags=aionotify.Flags.MODIFY, alias='/var/log') 79 | 80 | 81 | A watch can be removed by using its alias: 82 | 83 | .. code-block:: python 84 | 85 | watcher = aionotify.Watcher() 86 | watcher.watch('/var/log', flags=aionotify.Flags.MODIFY) 87 | 88 | watcher.unwatch('/var/log') 89 | -------------------------------------------------------------------------------- /aionotify/__init__.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2016 The aionotify project 2 | # This code is distributed under the two-clause BSD License. 3 | 4 | from importlib.metadata import version 5 | 6 | from .enums import Flags 7 | from .base import Watcher 8 | 9 | __all__ = ['Flags', 'Watcher'] 10 | 11 | 12 | __version__ = version("aionotify") 13 | __author__ = 'Raphaël Barrois ' 14 | -------------------------------------------------------------------------------- /aionotify/aioutils.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2016 The aionotify project 2 | # This code is distributed under the two-clause BSD License. 3 | 4 | import asyncio 5 | import asyncio.futures 6 | import errno 7 | import logging 8 | import os 9 | 10 | logger = logging.getLogger('asyncio.aionotify') 11 | 12 | 13 | class UnixFileDescriptorTransport(asyncio.ReadTransport): 14 | # Inspired from asyncio.unix_events._UnixReadPipeTransport 15 | max_size = 1024 16 | 17 | def __init__(self, loop, fileno, protocol, waiter=None): 18 | super().__init__() 19 | self._loop = loop 20 | self._fileno = fileno 21 | self._protocol = protocol 22 | 23 | self._active = False 24 | self._closing = False 25 | 26 | self._loop.call_soon(self._protocol.connection_made, self) 27 | # only start reading when connection_made() has been called. 28 | self._loop.call_soon(self.resume_reading) 29 | if waiter is not None: 30 | # only wake up the waiter when connection_made() has been called. 31 | self._loop.call_soon(self._notify_waiter, waiter) 32 | 33 | def _notify_waiter(self, waiter): 34 | if not waiter.cancelled(): 35 | waiter.set_result(None) 36 | 37 | def _read_ready(self): 38 | """Called by the event loop whenever the fd is ready for reading.""" 39 | 40 | try: 41 | data = os.read(self._fileno, self.max_size) 42 | except InterruptedError: 43 | # No worries ;) 44 | pass 45 | except OSError as exc: 46 | # Some OS-level problem, crash. 47 | self._fatal_error(exc, "Fatal read error on file descriptor read") 48 | else: 49 | if data: 50 | self._protocol.data_received(data) 51 | else: 52 | # We reached end-of-file. 53 | if self._loop.get_debug(): 54 | logger.info("%r was closed by the kernel", self) 55 | self._closing = False 56 | self.pause_reading() 57 | self._loop.call_soon(self._protocol.eof_received) 58 | self._loop.call_soon(self._call_connection_lost, None) 59 | 60 | def pause_reading(self): 61 | """Public API: pause reading the transport.""" 62 | self._loop.remove_reader(self._fileno) 63 | self._active = False 64 | 65 | def resume_reading(self): 66 | """Public API: resume transport reading.""" 67 | self._loop.add_reader(self._fileno, self._read_ready) 68 | self._active = True 69 | 70 | def close(self): 71 | """Public API: close the transport.""" 72 | if not self._closing: 73 | self._close() 74 | 75 | def _fatal_error(self, exc, message): 76 | if isinstance(exc, OSError) and exc.errno == errno.EIO: 77 | if self._loop.get_debug(): 78 | logger.debug("%r: %s", self, message, exc_info=True) 79 | else: 80 | self._loop.call_exception_handler({ 81 | 'message': message, 82 | 'exception': exc, 83 | 'transport': self, 84 | 'protocol': self._protocol, 85 | }) 86 | self._close(error=exc) 87 | 88 | def _close(self, error=None): 89 | """Actual closing code, both from manual close and errors.""" 90 | self._closing = True 91 | self.pause_reading() 92 | self._loop.call_soon(self._call_connection_lost, error) 93 | 94 | def _call_connection_lost(self, error): 95 | """Finalize closing.""" 96 | try: 97 | self._protocol.connection_lost(error) 98 | finally: 99 | os.close(self._fileno) 100 | self._fileno = None 101 | self._protocol = None 102 | self._loop = None 103 | 104 | def __repr__(self): 105 | if self._active: 106 | status = 'active' 107 | elif self._closing: 108 | status = 'closing' 109 | elif self._fileno: 110 | status = 'paused' 111 | else: 112 | status = 'closed' 113 | 114 | parts = [ 115 | self.__class__.__name__, 116 | status, 117 | 'fd=%s' % self._fileno, 118 | ] 119 | return '<%s>' % ' '.join(parts) 120 | 121 | 122 | async def stream_from_fd(fd, loop): 123 | """Recieve a streamer for a given file descriptor.""" 124 | reader = asyncio.StreamReader(loop=loop) 125 | protocol = asyncio.StreamReaderProtocol(reader, loop=loop) 126 | waiter = asyncio.futures.Future(loop=loop) 127 | 128 | transport = UnixFileDescriptorTransport( 129 | loop=loop, 130 | fileno=fd, 131 | protocol=protocol, 132 | waiter=waiter, 133 | ) 134 | 135 | try: 136 | await waiter 137 | except Exception: 138 | transport.close() 139 | 140 | if loop.get_debug(): 141 | logger.debug("Read fd %r connected: (%r, %r)", fd, transport, protocol) 142 | return reader, transport 143 | -------------------------------------------------------------------------------- /aionotify/base.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2016 The aionotify project 2 | # This code is distributed under the two-clause BSD License. 3 | 4 | import asyncio 5 | import collections 6 | import ctypes 7 | import struct 8 | 9 | from . import aioutils 10 | 11 | Event = collections.namedtuple('Event', ['flags', 'cookie', 'name', 'alias']) 12 | 13 | 14 | _libc = ctypes.cdll.LoadLibrary('libc.so.6') 15 | 16 | 17 | class LibC: 18 | """Proxy to C functions for inotify""" 19 | @classmethod 20 | def inotify_init(cls): 21 | return _libc.inotify_init() 22 | 23 | @classmethod 24 | def inotify_add_watch(cls, fd, path, flags): 25 | return _libc.inotify_add_watch(fd, path.encode('utf-8'), flags) 26 | 27 | @classmethod 28 | def inotify_rm_watch(cls, fd, wd): 29 | return _libc.inotify_rm_watch(fd, wd) 30 | 31 | 32 | PREFIX = struct.Struct('iIII') 33 | 34 | 35 | class Watcher: 36 | 37 | def __init__(self): 38 | self.requests = {} 39 | self._reset() 40 | 41 | def _reset(self): 42 | self.descriptors = {} 43 | self.aliases = {} 44 | self._stream = None 45 | self._transport = None 46 | self._fd = None 47 | self._loop = None 48 | 49 | def watch(self, path, flags, *, alias=None): 50 | """Add a new watching rule.""" 51 | if alias is None: 52 | alias = path 53 | if alias in self.requests: 54 | raise ValueError("A watch request is already scheduled for alias %s" % alias) 55 | self.requests[alias] = (path, flags) 56 | if self._fd is not None: 57 | # We've started, register the watch immediately. 58 | self._setup_watch(alias, path, flags) 59 | 60 | def unwatch(self, alias): 61 | """Stop watching a given rule.""" 62 | if alias not in self.descriptors: 63 | raise ValueError("Unknown watch alias %s; current set is %r" % (alias, list(self.descriptors.keys()))) 64 | wd = self.descriptors[alias] 65 | errno = LibC.inotify_rm_watch(self._fd, wd) 66 | if errno != 0: 67 | raise IOError("Failed to close watcher %d: errno=%d" % (wd, errno)) 68 | del self.descriptors[alias] 69 | del self.requests[alias] 70 | del self.aliases[wd] 71 | 72 | def _setup_watch(self, alias, path, flags): 73 | """Actual rule setup.""" 74 | assert alias not in self.descriptors, "Registering alias %s twice!" % alias 75 | wd = LibC.inotify_add_watch(self._fd, path, flags) 76 | if wd < 0: 77 | raise IOError("Error setting up watch on %s with flags %s: wd=%s" % ( 78 | path, flags, wd)) 79 | self.descriptors[alias] = wd 80 | self.aliases[wd] = alias 81 | 82 | async def setup(self, loop=None): 83 | """Start the watcher, registering new watches if any.""" 84 | self._loop = loop or asyncio.get_running_loop() 85 | 86 | self._fd = LibC.inotify_init() 87 | for alias, (path, flags) in self.requests.items(): 88 | self._setup_watch(alias, path, flags) 89 | 90 | # We pass ownership of the fd to the transport; it will close it. 91 | self._stream, self._transport = await aioutils.stream_from_fd(self._fd, self._loop) 92 | 93 | def close(self): 94 | """Schedule closure. 95 | 96 | This will close the transport and all related resources. 97 | """ 98 | self._transport.close() 99 | self._reset() 100 | 101 | @property 102 | def closed(self): 103 | """Are we closed?""" 104 | return self._transport is None 105 | 106 | async def get_event(self): 107 | """Fetch an event. 108 | 109 | This coroutine will swallow events for removed watches. 110 | """ 111 | while True: 112 | prefix = await self._stream.readexactly(PREFIX.size) 113 | if prefix == b'': 114 | # We got closed, return None. 115 | return 116 | wd, flags, cookie, length = PREFIX.unpack(prefix) 117 | path = await self._stream.readexactly(length) 118 | 119 | # All async performed, time to look at the event's content. 120 | if wd not in self.aliases: 121 | # Event for a removed watch, skip it. 122 | continue 123 | 124 | decoded_path = struct.unpack('%ds' % length, path)[0].rstrip(b'\x00').decode('utf-8') 125 | return Event( 126 | flags=flags, 127 | cookie=cookie, 128 | name=decoded_path, 129 | alias=self.aliases[wd], 130 | ) 131 | -------------------------------------------------------------------------------- /aionotify/enums.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2016 The aionotify project 2 | # This code is distributed under the two-clause BSD License. 3 | 4 | import enum 5 | 6 | 7 | class Flags(enum.IntEnum): 8 | ACCESS = 0x00000001 #: File was accessed 9 | MODIFY = 0x00000002 #: File was modified 10 | ATTRIB = 0x00000004 #: Metadata changed 11 | CLOSE_WRITE = 0x00000008 #: Writable file was closed 12 | CLOSE_NOWRITE = 0x00000010 #: Unwritable file closed 13 | OPEN = 0x00000020 #: File was opened 14 | MOVED_FROM = 0x00000040 #: File was moved from X 15 | MOVED_TO = 0x00000080 #: File was moved to Y 16 | CREATE = 0x00000100 #: Subfile was created 17 | DELETE = 0x00000200 #: Subfile was deleted 18 | DELETE_SELF = 0x00000400 #: Self was deleted 19 | MOVE_SELF = 0x00000800 #: Self was moved 20 | 21 | UNMOUNT = 0x00002000 #: Backing fs was unmounted 22 | Q_OVERFLOW = 0x00004000 #: Event queue overflowed 23 | IGNORED = 0x00008000 #: File was ignored 24 | 25 | ONLYDIR = 0x01000000 #: only watch the path if it is a directory 26 | DONT_FOLLOW = 0x02000000 #: don't follow a sym link 27 | EXCL_UNLINK = 0x04000000 #: exclude events on unlinked objects 28 | MASK_ADD = 0x20000000 #: add to the mask of an already existing watch 29 | ISDIR = 0x40000000 #: event occurred against dir 30 | ONESHOT = 0x80000000 #: only send event once 31 | 32 | @classmethod 33 | def parse(cls, flags): 34 | return [flag for flag in cls.__members__.values() if flag & flags] 35 | -------------------------------------------------------------------------------- /examples/print.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # Copyright (c) 2016 The aionotify project 3 | # This code is distributed under the two-clause BSD License. 4 | 5 | 6 | import aionotify 7 | import argparse 8 | import asyncio 9 | import logging 10 | import signal 11 | 12 | 13 | class Example: 14 | def __init__(self): 15 | self.loop = None 16 | self.watcher = None 17 | self.task = None 18 | 19 | def prepare(self, path): 20 | self.watcher = aionotify.Watcher() 21 | self.watcher.watch(path, aionotify.Flags.MODIFY | aionotify.Flags.CREATE | aionotify.Flags.DELETE) 22 | 23 | @asyncio.coroutine 24 | def _run(self, max_events): 25 | yield from self.watcher.setup(self.loop) 26 | for _i in range(max_events): 27 | event = yield from self.watcher.get_event() 28 | print(event.name, aionotify.Flags.parse(event.flags)) 29 | self.shutdown() 30 | 31 | def run(self, loop, max_events): 32 | self.loop = loop 33 | self.task = loop.create_task(self._run(max_events)) 34 | 35 | def shutdown(self): 36 | self.watcher.close() 37 | if self.task is not None: 38 | self.task.cancel() 39 | self.loop.stop() 40 | 41 | 42 | def setup_signal_handlers(loop, example): 43 | for sig in [signal.SIGINT, signal.SIGTERM]: 44 | loop.add_signal_handler(sig, example.shutdown) 45 | 46 | 47 | def main(args): 48 | if args.debug: 49 | logger = logging.getLogger('asyncio') 50 | logger.setLevel(logging.DEBUG) 51 | logger.addHandler(logging.StreamHandler()) 52 | 53 | example = Example() 54 | example.prepare(args.path) 55 | 56 | loop = asyncio.get_event_loop() 57 | if args.debug: 58 | loop.set_debug(True) 59 | 60 | setup_signal_handlers(loop, example) 61 | example.run(loop, args.events) 62 | 63 | try: 64 | loop.run_forever() 65 | finally: 66 | loop.close() 67 | 68 | 69 | if __name__ == '__main__': 70 | parser = argparse.ArgumentParser() 71 | parser.add_argument('path', help="Path to watch") 72 | parser.add_argument('--events', default=10, type=int, help="Number of arguments before shutdown") 73 | parser.add_argument('-d', '--debug', action='store_true', help="Enable asyncio debugging.") 74 | 75 | args = parser.parse_args() 76 | main(args) 77 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [metadata] 2 | name = aionotify 3 | version = 0.3.2.dev0 4 | description = Asyncio-powered inotify library 5 | long_description = file: README.rst 6 | # https://docutils.sourceforge.io/FAQ.html#what-s-the-official-mime-type-for-restructuredtext-data 7 | long_description_content_type = text/x-rst 8 | author = Raphaël Barrois 9 | author_email = raphael.barrois+aionotify@polytechnique.org 10 | url = https://github.com/rbarrois/aionotify 11 | keywords = asyncio, inotify 12 | license = BSD 13 | license_file = LICENSE 14 | classifiers = 15 | Development Status :: 4 - Beta 16 | Intended Audience :: Developers 17 | License :: OSI Approved :: BSD License 18 | Topic :: Software Development :: Libraries :: Python Modules 19 | Operating System :: POSIX :: Linux 20 | Programming Language :: Python 21 | Programming Language :: Python :: 3.10 22 | Programming Language :: Python :: 3.11 23 | Programming Language :: Python :: 3.12 24 | Topic :: Software Development :: Libraries :: Python Modules 25 | Topic :: System :: Filesystems 26 | 27 | [options] 28 | zip_safe = false 29 | packages = aionotify 30 | python_requires = >= 3.8 31 | install_requires = 32 | 33 | [options.extras_require] 34 | dev = 35 | # Runners 36 | tox 37 | # Quality 38 | check_manifest 39 | flake8 40 | # Packaging 41 | wheel 42 | zest.releaser[recommended] 43 | readme_renderer<25.0; python_version == "3.4" 44 | colorama<=0.4.1; python_version == "3.4" 45 | 46 | [bdist_wheel] 47 | universal = 1 48 | 49 | [zest.releaser] 50 | ; semver-style versions 51 | version-levels = 3 52 | 53 | [distutils] 54 | index-servers = pypi 55 | 56 | [flake8] 57 | # Ignore "and" at start of line. 58 | ignore = W503 59 | max-line-length = 120 60 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # Copyright (c) 2016-2024 The aionotify project 3 | # This code is distributed under the two-clause BSD License. 4 | 5 | 6 | from setuptools import setup 7 | 8 | setup() 9 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rbarrois/aionotify/dafcd5f177eecf582b439157e0361bf0cda32403/tests/__init__.py -------------------------------------------------------------------------------- /tests/test_enums.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2016 The aionotify project 2 | # This code is distributed under the two-clause BSD License. 3 | 4 | import unittest 5 | 6 | import aionotify 7 | 8 | 9 | class EnumsTests(unittest.TestCase): 10 | def test_parsing(self): 11 | Flags = aionotify.Flags 12 | 13 | flags = Flags.ACCESS | Flags.MODIFY | Flags.ATTRIB 14 | 15 | parsed = Flags.parse(flags) 16 | self.assertEqual([Flags.ACCESS, Flags.MODIFY, Flags.ATTRIB], parsed) 17 | -------------------------------------------------------------------------------- /tests/test_usage.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2016 The aionotify project 2 | # This code is distributed under the two-clause BSD License. 3 | 4 | import asyncio 5 | import logging 6 | import os 7 | import os.path 8 | import tempfile 9 | import unittest 10 | 11 | import aionotify 12 | 13 | 14 | AIODEBUG = bool(os.environ.get('PYTHONAIODEBUG') == '1') 15 | 16 | 17 | if AIODEBUG: 18 | logger = logging.getLogger('asyncio') 19 | logger.setLevel(logging.DEBUG) 20 | logger.addHandler(logging.StreamHandler()) 21 | 22 | 23 | TESTDIR = os.environ.get('AIOTESTDIR') or os.path.join(os.path.dirname(__file__), 'testevents') 24 | 25 | 26 | class AIONotifyTestCase(unittest.IsolatedAsyncioTestCase): 27 | timeout = 3 28 | 29 | def setUp(self): 30 | self.loop = asyncio.get_event_loop() 31 | if AIODEBUG: 32 | self.loop.set_debug(True) 33 | self.watcher = aionotify.Watcher() 34 | self._testdir = tempfile.TemporaryDirectory(dir=TESTDIR) 35 | self.testdir = self._testdir.name 36 | 37 | # Schedule a loop shutdown 38 | self.loop.call_later(self.timeout, self.loop.stop) 39 | 40 | def tearDown(self): 41 | if not self.watcher.closed: 42 | self.watcher.close() 43 | self._testdir.cleanup() 44 | self.assertFalse(os.path.exists(self.testdir)) 45 | 46 | # Utility functions 47 | # ================= 48 | 49 | # Those allow for more readable tests. 50 | 51 | def _touch(self, filename, *, parent=None): 52 | path = os.path.join(parent or self.testdir, filename) 53 | with open(path, 'w') as f: 54 | f.write('') 55 | 56 | def _unlink(self, filename, *, parent=None): 57 | path = os.path.join(parent or self.testdir, filename) 58 | os.unlink(path) 59 | 60 | def _rename(self, source, target, *, parent=None): 61 | source_path = os.path.join(parent or self.testdir, source) 62 | target_path = os.path.join(parent or self.testdir, target) 63 | os.rename(source_path, target_path) 64 | 65 | def _assert_file_event(self, event, name, flags=aionotify.Flags.CREATE, alias=None): 66 | """Check for an expected file event. 67 | 68 | Allows for more readable tests. 69 | """ 70 | if alias is None: 71 | alias = self.testdir 72 | 73 | self.assertEqual(name, event.name) 74 | self.assertEqual(flags, event.flags) 75 | self.assertEqual(alias, event.alias) 76 | 77 | async def _assert_no_events(self, timeout=0.1): 78 | """Ensure that no events are left in the queue.""" 79 | task = self.watcher.get_event() 80 | try: 81 | result = await asyncio.wait_for(task, timeout) 82 | except asyncio.TimeoutError: 83 | # All fine: we didn't receive any event. 84 | pass 85 | else: 86 | raise AssertionError("Event %r occurred within timeout %s" % (result, timeout)) 87 | 88 | 89 | class SimpleUsageTests(AIONotifyTestCase): 90 | 91 | async def test_watch_before_start(self): 92 | """A watch call is valid before startup.""" 93 | self.watcher.watch(self.testdir, aionotify.Flags.CREATE) 94 | await self.watcher.setup(self.loop) 95 | 96 | # Touch a file: we get the event. 97 | self._touch('a') 98 | event = await self.watcher.get_event() 99 | self._assert_file_event(event, 'a') 100 | 101 | # And it's over. 102 | await self._assert_no_events() 103 | 104 | async def test_watch_before_start_default_loop(self): 105 | """A watch call is valid before startup.""" 106 | self.watcher.watch(self.testdir, aionotify.Flags.CREATE) 107 | await self.watcher.setup() 108 | 109 | # Touch a file: we get the event. 110 | self._touch('a') 111 | event = await self.watcher.get_event() 112 | self._assert_file_event(event, 'a') 113 | 114 | # And it's over. 115 | await self._assert_no_events() 116 | 117 | async def test_watch_after_start(self): 118 | """A watch call is valid after startup.""" 119 | await self.watcher.setup(self.loop) 120 | self.watcher.watch(self.testdir, aionotify.Flags.CREATE) 121 | 122 | # Touch a file: we get the event. 123 | self._touch('a') 124 | event = await self.watcher.get_event() 125 | self._assert_file_event(event, 'a') 126 | 127 | # And it's over. 128 | await self._assert_no_events() 129 | 130 | async def test_event_ordering(self): 131 | """Events should arrive in the order files where created.""" 132 | await self.watcher.setup(self.loop) 133 | self.watcher.watch(self.testdir, aionotify.Flags.CREATE) 134 | 135 | # Touch 2 files 136 | self._touch('a') 137 | self._touch('b') 138 | 139 | # Get the events 140 | event1 = await self.watcher.get_event() 141 | event2 = await self.watcher.get_event() 142 | self._assert_file_event(event1, 'a') 143 | self._assert_file_event(event2, 'b') 144 | 145 | # And it's over. 146 | await self._assert_no_events() 147 | 148 | async def test_filtering_events(self): 149 | """We only get targeted events.""" 150 | await self.watcher.setup(self.loop) 151 | self.watcher.watch(self.testdir, aionotify.Flags.CREATE) 152 | self._touch('a') 153 | event = await self.watcher.get_event() 154 | self._assert_file_event(event, 'a') 155 | 156 | # Perform a filtered-out event; we shouldn't see anything 157 | self._unlink('a') 158 | await self._assert_no_events() 159 | 160 | async def test_watch_unwatch(self): 161 | """Watches can be removed.""" 162 | self.watcher.watch(self.testdir, aionotify.Flags.CREATE) 163 | await self.watcher.setup(self.loop) 164 | 165 | self.watcher.unwatch(self.testdir) 166 | await asyncio.sleep(0.1) 167 | 168 | # Touch a file; we shouldn't see anything. 169 | self._touch('a') 170 | await self._assert_no_events() 171 | 172 | async def test_watch_unwatch_before_drain(self): 173 | """Watches can be removed, no events occur afterwards.""" 174 | self.watcher.watch(self.testdir, aionotify.Flags.CREATE) 175 | await self.watcher.setup(self.loop) 176 | 177 | # Touch a file before unwatching 178 | self._touch('a') 179 | self.watcher.unwatch(self.testdir) 180 | 181 | # We shouldn't see anything. 182 | await self._assert_no_events() 183 | 184 | async def test_rename_detection(self): 185 | """A file rename can be detected through event cookies.""" 186 | self.watcher.watch(self.testdir, aionotify.Flags.MOVED_FROM | aionotify.Flags.MOVED_TO) 187 | await self.watcher.setup(self.loop) 188 | self._touch('a') 189 | 190 | # Rename a file => two events 191 | self._rename('a', 'b') 192 | event1 = await self.watcher.get_event() 193 | event2 = await self.watcher.get_event() 194 | 195 | # We got moved_from then moved_to; they share the same cookie. 196 | self._assert_file_event(event1, 'a', aionotify.Flags.MOVED_FROM) 197 | self._assert_file_event(event2, 'b', aionotify.Flags.MOVED_TO) 198 | self.assertEqual(event1.cookie, event2.cookie) 199 | 200 | # And it's over. 201 | await self._assert_no_events() 202 | 203 | 204 | class ErrorTests(AIONotifyTestCase): 205 | """Test error cases.""" 206 | 207 | async def test_watch_nonexistent(self): 208 | """Watching a non-existent directory raises an OSError.""" 209 | badpath = os.path.join(self.testdir, 'nonexistent') 210 | self.watcher.watch(badpath, aionotify.Flags.CREATE) 211 | with self.assertRaises(OSError): 212 | await self.watcher.setup(self.loop) 213 | 214 | async def test_unwatch_bad_alias(self): 215 | self.watcher.watch(self.testdir, aionotify.Flags.CREATE) 216 | await self.watcher.setup(self.loop) 217 | with self.assertRaises(ValueError): 218 | self.watcher.unwatch('blah') 219 | 220 | 221 | class SanityTests(AIONotifyTestCase): 222 | timeout = 0.1 223 | 224 | @unittest.expectedFailure 225 | async def test_timeout_works(self): 226 | """A test cannot run longer than the defined timeout.""" 227 | # This test should fail, since we're setting a global timeout of 0.1 yet ask to wait for 0.3 seconds. 228 | await asyncio.sleep(0.5) 229 | -------------------------------------------------------------------------------- /tests/testevents/.keep_dir: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rbarrois/aionotify/dafcd5f177eecf582b439157e0361bf0cda32403/tests/testevents/.keep_dir -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [tox] 2 | envlist = 3 | python-{3.8,3.9,3.10,3.11,3.12} 4 | lint 5 | toxworkdir = {env:TOX_WORKDIR:.tox} 6 | 7 | [testenv] 8 | allowlist_externals = make 9 | commands = make 10 | extras = dev 11 | setenv = 12 | PYTHONAIODEBUG=1 13 | 14 | [testenv:lint] 15 | allowlist_externals = make 16 | extras = dev 17 | commands = make lint 18 | --------------------------------------------------------------------------------