pax_global_header00006660000000000000000000000064151077151670014524gustar00rootroot0000000000000052 comment=320e1ee389b4536cc28d45a5ab10707a22789dc8 ProCern-asyncinotify-320e1ee/000077500000000000000000000000001510771516700162175ustar00rootroot00000000000000ProCern-asyncinotify-320e1ee/.gitignore000066400000000000000000000025131510771516700202100ustar00rootroot00000000000000# Byte-compiled / optimized / DLL files __pycache__/ *.py[cod] *$py.class # C extensions *.so # Distribution / packaging .Python build/ develop-eggs/ dist/ downloads/ eggs/ .eggs/ lib/ lib64/ parts/ sdist/ var/ wheels/ share/python-wheels/ *.egg-info/ .installed.cfg *.egg MANIFEST # PyInstaller # Usually these files are written by a python script from a template # before PyInstaller builds the exe, so as to inject date/other infos into it. *.manifest *.spec # Installer logs pip-log.txt pip-delete-this-directory.txt # Unit test / coverage reports htmlcov/ .tox/ .nox/ .coverage .coverage.* .cache nosetests.xml coverage.xml *.cover .hypothesis/ .pytest_cache/ # Translations *.mo *.pot # Django stuff: *.log local_settings.py db.sqlite3 # Flask stuff: instance/ .webassets-cache # Scrapy stuff: .scrapy # Sphinx documentation docs/_build/ # PyBuilder target/ # Jupyter Notebook .ipynb_checkpoints # IPython profile_default/ ipython_config.py # pyenv .python-version # celery beat schedule file celerybeat-schedule # SageMath parsed files *.sage.py # Environments .env .venv env/ venv/ ENV/ env.bak/ venv.bak/ # Spyder project settings .spyderproject .spyproject # Rope project settings .ropeproject # mkdocs documentation /site # mypy .mypy_cache/ .dmypy.json dmypy.json # Pyre type checker .pyre/ # sphinx build dir _build/ ProCern-asyncinotify-320e1ee/.gitlab-ci.yml000066400000000000000000000025021510771516700206520ustar00rootroot00000000000000image: python:latest # Change pip's cache directory to be inside the project directory since we can # only cache local items. variables: PIP_CACHE_DIR: "$CI_PROJECT_DIR/.cache/pip" stages: - package - test - deploy # Pip's cache doesn't store the python packages # https://pip.pypa.io/en/stable/topics/caching/ # # If you want to also cache the installed packages, you have to install # them in a virtualenv and cache it as well. cache: paths: - .cache/pip - venv/ before_script: - python --version # For debugging - python -mvenv venv - . venv/bin/activate package: stage: package script: - pip install wheel 'flit >=3.2, <4' build - python -mbuild artifacts: paths: - dist/*.whl - dist/*.tar.gz test: stage: test parallel: matrix: - VERSION: - '3.6' - '3.7' - '3.8' - '3.9' - '3.10' - '3.11' - '3.12' image: python:$VERSION script: - pip install ./dist/*.whl - python test.py deploy: stage: deploy script: - pip install wheel 'flit >=3.2, <4' build - echo '[pypi]' >"$HOME/.pypirc" - echo 'username = __token__' >>"$HOME/.pypirc" - echo "password = ${PYPI_TOKEN:?Only works on protected tags}" >>"$HOME/.pypirc" - flit publish rules: - if: $CI_COMMIT_TAG =~ /^v[0-9]/ ProCern-asyncinotify-320e1ee/.readthedocs.yaml000066400000000000000000000002471510771516700214510ustar00rootroot00000000000000version: 2 build: os: ubuntu-22.04 tools: python: "3.12" sphinx: configuration: docs/conf.py python: install: - requirements: docs/requirements.txt ProCern-asyncinotify-320e1ee/CHANGELOG.md000066400000000000000000000001711510771516700200270ustar00rootroot00000000000000# Changelog ## [1.0.0] - 2019-11-15 ### Added - `Event.__contains__` method for checking `Mask` membership - Unit tests ProCern-asyncinotify-320e1ee/CONTRIBUTING.md000066400000000000000000000000001510771516700204360ustar00rootroot00000000000000ProCern-asyncinotify-320e1ee/LICENSE000066400000000000000000000405261510771516700172330ustar00rootroot00000000000000Mozilla Public License Version 2.0 ================================== 1. Definitions -------------- 1.1. "Contributor" means each individual or legal entity that creates, contributes to the creation of, or owns Covered Software. 1.2. "Contributor Version" means the combination of the Contributions of others (if any) used by a Contributor and that particular Contributor's Contribution. 1.3. "Contribution" means Covered Software of a particular Contributor. 1.4. "Covered Software" means Source Code Form to which the initial Contributor has attached the notice in Exhibit A, the Executable Form of such Source Code Form, and Modifications of such Source Code Form, in each case including portions thereof. 1.5. "Incompatible With Secondary Licenses" means (a) that the initial Contributor has attached the notice described in Exhibit B to the Covered Software; or (b) that the Covered Software was made available under the terms of version 1.1 or earlier of the License, but not also under the terms of a Secondary License. 1.6. "Executable Form" means any form of the work other than Source Code Form. 1.7. "Larger Work" means a work that combines Covered Software with other material, in a separate file or files, that is not Covered Software. 1.8. "License" means this document. 1.9. "Licensable" means having the right to grant, to the maximum extent possible, whether at the time of the initial grant or subsequently, any and all of the rights conveyed by this License. 1.10. "Modifications" means any of the following: (a) any file in Source Code Form that results from an addition to, deletion from, or modification of the contents of Covered Software; or (b) any new file in Source Code Form that contains any Covered Software. 1.11. "Patent Claims" of a Contributor means any patent claim(s), including without limitation, method, process, and apparatus claims, in any patent Licensable by such Contributor that would be infringed, but for the grant of the License, by the making, using, selling, offering for sale, having made, import, or transfer of either its Contributions or its Contributor Version. 1.12. "Secondary License" means either the GNU General Public License, Version 2.0, the GNU Lesser General Public License, Version 2.1, the GNU Affero General Public License, Version 3.0, or any later versions of those licenses. 1.13. "Source Code Form" means the form of the work preferred for making modifications. 1.14. "You" (or "Your") means an individual or a legal entity exercising rights under this License. For legal entities, "You" includes any entity that controls, is controlled by, or is under common control with You. For purposes of this definition, "control" means (a) the power, direct or indirect, to cause the direction or management of such entity, whether by contract or otherwise, or (b) ownership of more than fifty percent (50%) of the outstanding shares or beneficial ownership of such entity. 2. License Grants and Conditions -------------------------------- 2.1. Grants Each Contributor hereby grants You a world-wide, royalty-free, non-exclusive license: (a) under intellectual property rights (other than patent or trademark) Licensable by such Contributor to use, reproduce, make available, modify, display, perform, distribute, and otherwise exploit its Contributions, either on an unmodified basis, with Modifications, or as part of a Larger Work; and (b) under Patent Claims of such Contributor to make, use, sell, offer for sale, have made, import, and otherwise transfer either its Contributions or its Contributor Version. 2.2. Effective Date The licenses granted in Section 2.1 with respect to any Contribution become effective for each Contribution on the date the Contributor first distributes such Contribution. 2.3. Limitations on Grant Scope The licenses granted in this Section 2 are the only rights granted under this License. No additional rights or licenses will be implied from the distribution or licensing of Covered Software under this License. Notwithstanding Section 2.1(b) above, no patent license is granted by a Contributor: (a) for any code that a Contributor has removed from Covered Software; or (b) for infringements caused by: (i) Your and any other third party's modifications of Covered Software, or (ii) the combination of its Contributions with other software (except as part of its Contributor Version); or (c) under Patent Claims infringed by Covered Software in the absence of its Contributions. This License does not grant any rights in the trademarks, service marks, or logos of any Contributor (except as may be necessary to comply with the notice requirements in Section 3.4). 2.4. Subsequent Licenses No Contributor makes additional grants as a result of Your choice to distribute the Covered Software under a subsequent version of this License (see Section 10.2) or under the terms of a Secondary License (if permitted under the terms of Section 3.3). 2.5. Representation Each Contributor represents that the Contributor believes its Contributions are its original creation(s) or it has sufficient rights to grant the rights to its Contributions conveyed by this License. 2.6. Fair Use This License is not intended to limit any rights You have under applicable copyright doctrines of fair use, fair dealing, or other equivalents. 2.7. Conditions Sections 3.1, 3.2, 3.3, and 3.4 are conditions of the licenses granted in Section 2.1. 3. Responsibilities ------------------- 3.1. Distribution of Source Form All distribution of Covered Software in Source Code Form, including any Modifications that You create or to which You contribute, must be under the terms of this License. You must inform recipients that the Source Code Form of the Covered Software is governed by the terms of this License, and how they can obtain a copy of this License. You may not attempt to alter or restrict the recipients' rights in the Source Code Form. 3.2. Distribution of Executable Form If You distribute Covered Software in Executable Form then: (a) such Covered Software must also be made available in Source Code Form, as described in Section 3.1, and You must inform recipients of the Executable Form how they can obtain a copy of such Source Code Form by reasonable means in a timely manner, at a charge no more than the cost of distribution to the recipient; and (b) You may distribute such Executable Form under the terms of this License, or sublicense it under different terms, provided that the license for the Executable Form does not attempt to limit or alter the recipients' rights in the Source Code Form under this License. 3.3. Distribution of a Larger Work You may create and distribute a Larger Work under terms of Your choice, provided that You also comply with the requirements of this License for the Covered Software. If the Larger Work is a combination of Covered Software with a work governed by one or more Secondary Licenses, and the Covered Software is not Incompatible With Secondary Licenses, this License permits You to additionally distribute such Covered Software under the terms of such Secondary License(s), so that the recipient of the Larger Work may, at their option, further distribute the Covered Software under the terms of either this License or such Secondary License(s). 3.4. Notices You may not remove or alter the substance of any license notices (including copyright notices, patent notices, disclaimers of warranty, or limitations of liability) contained within the Source Code Form of the Covered Software, except that You may alter any license notices to the extent required to remedy known factual inaccuracies. 3.5. Application of Additional Terms You may choose to offer, and to charge a fee for, warranty, support, indemnity or liability obligations to one or more recipients of Covered Software. However, You may do so only on Your own behalf, and not on behalf of any Contributor. You must make it absolutely clear that any such warranty, support, indemnity, or liability obligation is offered by You alone, and You hereby agree to indemnify every Contributor for any liability incurred by such Contributor as a result of warranty, support, indemnity or liability terms You offer. You may include additional disclaimers of warranty and limitations of liability specific to any jurisdiction. 4. Inability to Comply Due to Statute or Regulation --------------------------------------------------- If it is impossible for You to comply with any of the terms of this License with respect to some or all of the Covered Software due to statute, judicial order, or regulation then You must: (a) comply with the terms of this License to the maximum extent possible; and (b) describe the limitations and the code they affect. Such description must be placed in a text file included with all distributions of the Covered Software under this License. Except to the extent prohibited by statute or regulation, such description must be sufficiently detailed for a recipient of ordinary skill to be able to understand it. 5. Termination -------------- 5.1. The rights granted under this License will terminate automatically if You fail to comply with any of its terms. However, if You become compliant, then the rights granted under this License from a particular Contributor are reinstated (a) provisionally, unless and until such Contributor explicitly and finally terminates Your grants, and (b) on an ongoing basis, if such Contributor fails to notify You of the non-compliance by some reasonable means prior to 60 days after You have come back into compliance. Moreover, Your grants from a particular Contributor are reinstated on an ongoing basis if such Contributor notifies You of the non-compliance by some reasonable means, this is the first time You have received notice of non-compliance with this License from such Contributor, and You become compliant prior to 30 days after Your receipt of the notice. 5.2. If You initiate litigation against any entity by asserting a patent infringement claim (excluding declaratory judgment actions, counter-claims, and cross-claims) alleging that a Contributor Version directly or indirectly infringes any patent, then the rights granted to You by any and all Contributors for the Covered Software under Section 2.1 of this License shall terminate. 5.3. In the event of termination under Sections 5.1 or 5.2 above, all end user license agreements (excluding distributors and resellers) which have been validly granted by You or Your distributors under this License prior to termination shall survive termination. ************************************************************************ * * * 6. Disclaimer of Warranty * * ------------------------- * * * * Covered Software is provided under this License on an "as is" * * basis, without warranty of any kind, either expressed, implied, or * * statutory, including, without limitation, warranties that the * * Covered Software is free of defects, merchantable, fit for a * * particular purpose or non-infringing. The entire risk as to the * * quality and performance of the Covered Software is with You. * * Should any Covered Software prove defective in any respect, You * * (not any Contributor) assume the cost of any necessary servicing, * * repair, or correction. This disclaimer of warranty constitutes an * * essential part of this License. No use of any Covered Software is * * authorized under this License except under this disclaimer. * * * ************************************************************************ ************************************************************************ * * * 7. Limitation of Liability * * -------------------------- * * * * Under no circumstances and under no legal theory, whether tort * * (including negligence), contract, or otherwise, shall any * * Contributor, or anyone who distributes Covered Software as * * permitted above, be liable to You for any direct, indirect, * * special, incidental, or consequential damages of any character * * including, without limitation, damages for lost profits, loss of * * goodwill, work stoppage, computer failure or malfunction, or any * * and all other commercial damages or losses, even if such party * * shall have been informed of the possibility of such damages. This * * limitation of liability shall not apply to liability for death or * * personal injury resulting from such party's negligence to the * * extent applicable law prohibits such limitation. Some * * jurisdictions do not allow the exclusion or limitation of * * incidental or consequential damages, so this exclusion and * * limitation may not apply to You. * * * ************************************************************************ 8. Litigation ------------- Any litigation relating to this License may be brought only in the courts of a jurisdiction where the defendant maintains its principal place of business and such litigation shall be governed by laws of that jurisdiction, without reference to its conflict-of-law provisions. Nothing in this Section shall prevent a party's ability to bring cross-claims or counter-claims. 9. Miscellaneous ---------------- This License represents the complete agreement concerning the subject matter hereof. If any provision of this License is held to be unenforceable, such provision shall be reformed only to the extent necessary to make it enforceable. Any law or regulation which provides that the language of a contract shall be construed against the drafter shall not be used to construe this License against a Contributor. 10. Versions of the License --------------------------- 10.1. New Versions Mozilla Foundation is the license steward. Except as provided in Section 10.3, no one other than the license steward has the right to modify or publish new versions of this License. Each version will be given a distinguishing version number. 10.2. Effect of New Versions You may distribute the Covered Software under the terms of the version of the License under which You originally received the Covered Software, or under the terms of any subsequent version published by the license steward. 10.3. Modified Versions If you create software not governed by this License, and you want to create a new license for such software, you may create and use a modified version of this License if you rename the license and remove any references to the name of the license steward (except to note that such modified license differs from this License). 10.4. Distributing Source Code Form that is Incompatible With Secondary Licenses If You choose to distribute Source Code Form that is Incompatible With Secondary Licenses under the terms of this version of the License, the notice described in Exhibit B of this License must be attached. Exhibit A - Source Code Form License Notice ------------------------------------------- This Source Code Form is subject to the terms of the Mozilla Public License, v. 2.0. If a copy of the MPL was not distributed with this file, You can obtain one at https://mozilla.org/MPL/2.0/. If it is not possible or desirable to put the notice in a particular file, then You may include the notice in a location (such as a LICENSE file in a relevant directory) where a recipient would be likely to look for such a notice. You may add additional accurate notices of copyright ownership. Exhibit B - "Incompatible With Secondary Licenses" Notice --------------------------------------------------------- This Source Code Form is "Incompatible With Secondary Licenses", as defined by the Mozilla Public License, v. 2.0. ProCern-asyncinotify-320e1ee/README.rst000066400000000000000000000125071510771516700177130ustar00rootroot00000000000000asyncinotify ============ An async python inotify package. Kept as simple and easy-to-understand as possible, while still being flexible and powerful. This is built on no external dependencies, and works through ctypes in a very obvious fashion. This works without any other external dependencies. The code is available on GitHub_ and the documentation is available on ReadTheDocs_. The package itself is available on PyPi_. Installation ------------ You know the drill:: pip install asyncinotify Usage ----- The core of this package is ``asyncinotify.Inotify``. Most important Classes may be imported directly from the ``asyncinotify`` package. .. code-block:: python from pathlib import Path from asyncinotify import Inotify, Mask import asyncio async def main(): # Context manager to close the inotify handle after use with Inotify() as inotify: # Adding the watch can also be done outside of the context manager. # __enter__ doesn't actually do anything except return self. # This returns an asyncinotify.inotify.Watch instance inotify.add_watch('/tmp', Mask.ACCESS | Mask.MODIFY | Mask.OPEN | Mask.CREATE | Mask.DELETE | Mask.ATTRIB | Mask.CLOSE | Mask.MOVE | Mask.ONLYDIR) # Iterate events forever, yielding them one at a time async for event in inotify: # Events have a helpful __repr__. They also have a reference to # their Watch instance. print(event) # the contained path may or may not be valid UTF-8. See the note # below print(repr(event.path)) asyncio.run(main()) This will asynchronously watch the /tmp directory and report events it encounters. This library also supports synchronous operation, using the `asyncinotify.inotify.Inotify.sync_get`` method, or simply using synchronous iteration. Support ------- This is supported and tested on the following: * Python 3.6, 3.7, 3.8, 3.9, 3.10, 3.11, 3.12, 3.13, and 3.14 * Debian bullseye, bookworm, and trixie * Ubuntu 20.04, 22.04, and 24.04 * Fedora 42 and 43 * Alma Linux 8, 9 and 10 (Should be equivalent to RHEL) * Alpine Linux 3.19 through 3.22 We regularly remove out-of-support OSes from the tests and add new releases. We support back to Python 3.6 as long as it remains easy to do so. We may, in some future version, restrict Python support to non-EOL versions, if supporting the older versions becomes inconvenient. Motivation ---------- There are a few different python inotify packages. Most of them either have odd conventions, expose too much of the underlying C API in a way that I personally don't like, are badly documented, they work with paths in a non-idiomatic way, are not asynchronous, or are overengineered compared to the API they are wrapping. I find that the last one is true for the majority of them. I encourage everybody to read the `sources `_ of this package. They are quite simple and easy to understand. This library * Works in a very simple way. It does not have many add-ons or extra features beyond presenting a very Python interface to the raw inotify functionality. Any extra features (such as recursive watching) are presented as completely separate classes. The core inotify functionality is as pure as it can reasonably be. * Grabs events in bulk and caches them for minor performance gains. * Leverages IntFlag for all masks and flags, allowing the user to use the features of IntFlag, such as seeing individual applied flags in the ``repr``, checking for flag set bits with ``in``. * Exposes all paths via python's pathlib_ * Exposes all the functionality of inotify without depending on the user having to interact with any of the underlying mechanics of Inotify. You should never have to touch the inotify or watch descriptors for any reason. The primary motivation is that this is written to be a Python inotify module that I would feel comfortable using. .. _ospackage: https://docs.python.org/3/library/os.html#file-names-command-line-arguments-and-environment-variables .. _surrogateescape: https://docs.python.org/3/library/codecs.html#surrogateescape .. _GitHub: https://github.com/ProCern/asyncinotify .. _pathlib: https://docs.python.org/3/library/pathlib.html .. _ReadTheDocs: https://asyncinotify.readthedocs.io/en/latest/ .. _PyPi: https://pypi.org/project/asyncinotify/ License ------- This code is Copyright 2019 - 2023 Absolute Performance, Inc, and 2024 - 2025 ProCern Technology Solutions. It is written and maintained by `Taylor C. Richberger `_. This Source Code Form is subject to the terms of the Mozilla Public License, v. 2.0. If a copy of the MPL was not distributed with this file, You can obtain one at https://mozilla.org/MPL/2.0/. Note that this does **not** have the Exhibit B - “Incompatible With Secondary Licenses” Notice. This software is explicitly **compatible** with secondary licenses. It is MPL 2.0 to ensure that any improvements and other changes are distributed. We do not want to inhibit nor prohibit its use with other FOSS or proprietary software, regardless of the license. In other words, if you aren't changing asyncinotify, you can pretty much just use it as a library without worrying, unless your other license is explicitly or implicitly incompatible with it (which should be incredibly uncommon). ProCern-asyncinotify-320e1ee/docs/000077500000000000000000000000001510771516700171475ustar00rootroot00000000000000ProCern-asyncinotify-320e1ee/docs/Makefile000066400000000000000000000011721510771516700206100ustar00rootroot00000000000000# Minimal makefile for Sphinx documentation # # You can set these variables from the command line, and also # from the environment for the first two. SPHINXOPTS ?= SPHINXBUILD ?= sphinx-build SOURCEDIR = . BUILDDIR = _build # Put it first so that "make" without argument is like "make help". help: @$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) .PHONY: help Makefile # Catch-all target: route all unknown targets to Sphinx using the new # "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS). %: Makefile @$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) ProCern-asyncinotify-320e1ee/docs/asyncinotify.rst000066400000000000000000000001041510771516700224130ustar00rootroot00000000000000asyncinotify ============ .. automodule:: asyncinotify :members: ProCern-asyncinotify-320e1ee/docs/conf.py000066400000000000000000000052611510771516700204520ustar00rootroot00000000000000#!/usr/bin/env python3 # -*- coding: utf-8 -*- # This Source Code Form is subject to the terms of the Mozilla Public License, # v. 2.0. If a copy of the MPL was not distributed with this file, You can # obtain one at https://mozilla.org/MPL/2.0/. # This code is Copyright 2019 - 2023 Absolute Performance, Inc, and 2024 - 2025 # ProCern Technology Solutions. # It is written and maintained by Taylor C. Richberger # # Configuration file for the Sphinx documentation builder. # # This file only contains a selection of the most common options. For a full # list see the documentation: # http://www.sphinx-doc.org/en/master/config # -- Path setup -------------------------------------------------------------- # 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. # import sys from pathlib import Path import os root = Path(__file__).resolve().parent.parent sys.path.insert(0, str(root / 'src')) # -- Project information ----------------------------------------------------- try: import tomllib except ImportError: import tomli as tomllib # type: ignore with (root / 'pyproject.toml').open('rb') as file: pyproject = tomllib.load(file) project = pyproject['project']['name'] author = pyproject['project']['authors'][0]['name'] copyright = '2019-2025 ProCern' os.environ['READTHEDOCS'] = 'True' import asyncinotify version = asyncinotify.__version__ release = asyncinotify.__version__ # -- General configuration --------------------------------------------------- # Add any Sphinx extension module names here, as strings. They can be # extensions coming with Sphinx (named 'sphinx.ext.*') or your custom # ones. extensions = [ 'sphinx.ext.autodoc', ] # Add any paths that contain templates here, relative to this directory. templates_path = ['_templates'] # List of patterns, relative to source directory, that match files and # directories to ignore when looking for source files. # This pattern also affects html_static_path and html_extra_path. exclude_patterns = ['_build', 'Thumbs.db', '.DS_Store'] # -- Options for HTML output ------------------------------------------------- # The theme to use for HTML and HTML Help pages. See the documentation for # a list of builtin themes. # html_theme = 'furo' # Add any paths that contain custom static files (such as style sheets) here, # relative to this directory. They are copied after the builtin static files, # so a file named "default.css" will overwrite the builtin "default.css". html_static_path = ['_static'] master_doc = 'index' ProCern-asyncinotify-320e1ee/docs/index.rst000066400000000000000000000030431510771516700210100ustar00rootroot00000000000000.. asyncinotify documentation master file, created by sphinx-quickstart on Fri Nov 15 09:56:23 2019. You can adapt this file completely to your liking, but it should at least contain the root `toctree` directive. .. include:: ../README.rst .. warning:: This package handles the watch paths and event names and paths as :class:`pathlib.Path` instances. These might not be valid utf-8, because Linux paths may contain any character except for the null byte, including invalid utf-8 sequences. This library uses ``os.fsencode`` and ``os.fsdecode`` on paths to obtain paths the same way that Python natively does. You can read more about Python's path handling in the `filesystem encoding and error handler `_ section of the glossary. This section links to the relevant places, and you can use some of this to figure out how to handle non-UTF-8 sequences on a UTF-8 system. .. toctree:: :maxdepth: 2 :caption: Contents: asyncinotify Indices and tables ================== * :ref:`genindex` * :ref:`modindex` * :ref:`search` .. _ospackage: https://docs.python.org/3/library/os.html#file-names-command-line-arguments-and-environment-variables .. _errorhandler: https://docs.python.org/3/library/codecs.html#error-handlers .. _GitHub: https://github.com/ProCern/asyncinotify .. _pathlib: https://docs.python.org/3/library/pathlib.html .. _ReadTheDocs: https://asyncinotify.readthedocs.io/en/latest/ .. _PyPi: https://pypi.org/project/asyncinotify/ ProCern-asyncinotify-320e1ee/docs/make.bat000066400000000000000000000014331510771516700205550ustar00rootroot00000000000000@ECHO OFF pushd %~dp0 REM Command file for Sphinx documentation if "%SPHINXBUILD%" == "" ( set SPHINXBUILD=sphinx-build ) set SOURCEDIR=. set BUILDDIR=_build if "%1" == "" goto help %SPHINXBUILD% >NUL 2>NUL if errorlevel 9009 ( echo. echo.The 'sphinx-build' command was not found. Make sure you have Sphinx echo.installed, then set the SPHINXBUILD environment variable to point echo.to the full path of the 'sphinx-build' executable. Alternatively you echo.may add the Sphinx directory to PATH. echo. echo.If you don't have Sphinx installed, grab it from echo.http://sphinx-doc.org/ exit /b 1 ) %SPHINXBUILD% -M %1 %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O% goto end :help %SPHINXBUILD% -M help %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O% :end popd ProCern-asyncinotify-320e1ee/docs/requirements.txt000066400000000000000000000000731510771516700224330ustar00rootroot00000000000000sphinx tomli; python_version < "3.11.0" furo == 2024.04.27 ProCern-asyncinotify-320e1ee/examples/000077500000000000000000000000001510771516700200355ustar00rootroot00000000000000ProCern-asyncinotify-320e1ee/examples/recursivewatch.py000066400000000000000000000107211510771516700234460ustar00rootroot00000000000000#!/usr/bin/env python3 # -*- coding: utf-8 -*- # This Source Code Form is subject to the terms of the Mozilla Public License, # v. 2.0. If a copy of the MPL was not distributed with this file, You can # obtain one at https://mozilla.org/MPL/2.0/. # This code is Copyright 2019 - 2023 Absolute Performance, Inc, and 2024 - 2025 # ProCern Technology Solutions. # It is written and maintained by Taylor C. Richberger # This is a sample simple async generator that tries its best to recursively watch a directory. import asyncio from asyncinotify import Inotify, Event, Mask from pathlib import Path from typing import Generator, AsyncGenerator def get_directories_recursive(path: Path) -> Generator[Path, None, None]: '''Recursively list all directories under path, including path itself, if it's a directory. The path itself is always yielded before its children are iterated, so you can pre-process a path (by watching it with inotify) before you get the directory listing. Passing a non-directory won't raise an error or anything, it'll just yield nothing. ''' if path.is_dir(): yield path for child in path.iterdir(): yield from get_directories_recursive(child) async def watch_recursive(path: Path, mask: Mask) -> AsyncGenerator[Event, None]: with Inotify() as inotify: for directory in get_directories_recursive(path): print(f'INIT: watching {directory}') inotify.add_watch(directory, mask | Mask.MOVED_FROM | Mask.MOVED_TO | Mask.CREATE | Mask.DELETE_SELF | Mask.IGNORED) # Things that can throw this off: # # * Moving a watched directory out of the watch tree (will still # generate events even when outside of directory tree) # # * Doing two changes on a directory or something before the program # has a time to handle it (this will also throw off a lot of inotify # code, though) # # * Moving a watched directory within a watched directory will get the # wrong path. This needs to use the cookie system to link events # together and complete the move properly, which can still make some # events get the wrong path if you get file events during the move or # something silly like that, since MOVED_FROM and MOVED_TO aren't # guaranteed to be contiguous. That exercise is left up to the # reader. # # * Trying to watch a path that doesn't exist won't automatically # create it or anything of the sort. # # * Deleting and recreating or moving the watched directory won't do # anything special, but it probably should. async for event in inotify: # Add subdirectories to watch if a new directory is added. We do # this recursively here before processing events to make sure we # have complete coverage of existing and newly-created directories # by watching before recursing and adding, since we know # get_directories_recursive is depth-first and yields every # directory before iterating their children, we know we won't miss # anything. if Mask.CREATE in event.mask and event.path is not None and event.path.is_dir(): for directory in get_directories_recursive(event.path): print(f'EVENT: watching {directory}') inotify.add_watch(directory, mask | Mask.MOVED_FROM | Mask.MOVED_TO | Mask.CREATE | Mask.DELETE_SELF | Mask.IGNORED) # If there is at least some overlap, assume the user wants this event. if event.mask & mask: yield event else: # Note that these events are needed for cleanup purposes. # We'll always get IGNORED events so the watch can be removed # from the inotify. We don't need to do anything with the # events, but they do need to be generated for cleanup. # We don't need to pass IGNORED events up, because the end-user # doesn't have the inotify instance anyway, and IGNORED is just # used for management purposes. print(f'UNYIELDED EVENT: {event}') async def main(): async for event in watch_recursive(Path('/tmp'), Mask.CREATE): print(f'MAIN: got {event} for path {event.path}') if __name__ == '__main__': asyncio.run(main()) ProCern-asyncinotify-320e1ee/justfile000077500000000000000000000055641510771516700200040ustar00rootroot00000000000000export runtest := ''' python3 -mvenv --system-site-packages /mnt/venv /mnt/venv/bin/pip install --upgrade wheel /mnt/venv/bin/pip install --upgrade pip /mnt/venv/bin/pip install "/mnt/app[test]" cd /mnt/app /mnt/venv/bin/python -munittest ''' dnf-setup := ''' dnf update -y dnf install -y python3 sqlite python3-pip ''' apt-setup := ''' apt update apt upgrade -y apt install -y python3 sqlite3 python3-pip python3-venv ''' apk-setup := ''' apk update apk upgrade apk add python3 sqlite ''' _list: @just --list # Run all tests test: python-tests debian-tests ubuntu-tests fedora-tests rhel-tests alpine-tests centos-stream-tests # Run a test with a given setup in the given container _run-test $container $setup="": #!/bin/sh set -euxf podman container run \ --rm \ -e PYTHONPATH=/mnt/app/src \ --mount type=volume,destination=/mnt/venv \ --mount type=bind,source=$(pwd),destination=/mnt/app,ro=true \ --security-opt label=disable \ "$container" \ /bin/sh -c " set -euxf $setup $runtest " # Test a particular python version test-python version="latest": (_run-test ("docker.io/python:" + version)) # Test all supported python versions python-tests: (test-python "3.6") (test-python "3.7") (test-python "3.8") (test-python "3.9") (test-python "3.10") (test-python "3.11") (test-python "3.12") (test-python "3.13") (test-python "3.14") # Test a particular alpine version test-alpine version="latest": (_run-test ("docker.io/alpine:" + version) apk-setup) # Test all supported alpine versions alpine-tests: (test-alpine "3.19") (test-alpine "3.20") (test-alpine "3.21") (test-alpine "3.22") _test-apt image="debian:latest": (_run-test image apt-setup) # Test a particular debian version test-debian version="latest": (_test-apt ("docker.io/debian:" + version)) # Test a particular ubuntu version test-ubuntu version="latest": (_test-apt ("docker.io/ubuntu:" + version)) # Test all supported debian versions debian-tests: (test-debian "bullseye") (test-debian "bookworm") (test-debian "trixie") # Test all supported ubuntu versions ubuntu-tests: (test-ubuntu "20.04") (test-ubuntu "22.04") (test-ubuntu "24.04") _test-dnf image="fedora:latest": (_run-test image dnf-setup) # Test a particular fedora version test-fedora version="latest": (_test-dnf ("docker.io/fedora:" + version)) # Test all supported fedora versions fedora-tests: (test-fedora "42") (test-fedora "43") # Test a particular RHEL version that is on dnf test-rhel version="9": (_test-dnf ("docker.io/almalinux:" + version)) # Test all supported RHEL versions rhel-tests: (test-rhel "8") (test-rhel "9") (test-rhel "10") # Test a particular RHEL version test-centos-stream version="stream10": (_test-dnf ("quay.io/centos/centos:" + version)) # Test all supported RHEL versions centos-stream-tests: (test-centos-stream "stream9") (test-centos-stream "stream10") ProCern-asyncinotify-320e1ee/pyproject.toml000066400000000000000000000015411510771516700211340ustar00rootroot00000000000000[project] name = 'asyncinotify' dynamic = ['description', 'version'] readme = 'README.rst' requires-python = '>= 3.6, < 4' license = 'MPL-2.0' keywords = [ 'async', 'inotify', ] classifiers=[ 'Development Status :: 6 - Mature', 'Programming Language :: Python :: 3', 'Programming Language :: Python :: 3.6', 'Operating System :: POSIX :: Linux', 'Topic :: Software Development :: Libraries', 'Topic :: Software Development :: Libraries :: Python Modules', 'Intended Audience :: Developers', 'Framework :: AsyncIO', ] [[project.authors]] name = "Taylor C. Richberger" email = "taylor.richberger@procern.com" [project.urls] repository = 'https://github.com/ProCern/asyncinotify/' documentation = 'https://asyncinotify.readthedocs.io/' [build-system] build-backend = "flit_core.buildapi" requires = ["flit_core >=3.12,<4"] ProCern-asyncinotify-320e1ee/src/000077500000000000000000000000001510771516700170065ustar00rootroot00000000000000ProCern-asyncinotify-320e1ee/src/asyncinotify/000077500000000000000000000000001510771516700215255ustar00rootroot00000000000000ProCern-asyncinotify-320e1ee/src/asyncinotify/__init__.py000066400000000000000000000730521510771516700236450ustar00rootroot00000000000000#!/usr/bin/env python3 # -*- coding: utf-8 -*- # This Source Code Form is subject to the terms of the Mozilla Public License, # v. 2.0. If a copy of the MPL was not distributed with this file, You can # obtain one at https://mozilla.org/MPL/2.0/. # This code is Copyright 2019 - 2023 Absolute Performance, Inc, and 2024 - 2025 # ProCern Technology Solutions. # It is written and maintained by Taylor C. Richberger ''''A simple optionally-async python inotify library, focused on simplicity of use and operation, and leveraging modern Python features''' __version__ = '4.3.2' from contextlib import contextmanager from enum import IntFlag from io import BytesIO from pathlib import Path, PurePath from typing import TYPE_CHECKING, Callable, Generator, Optional, Union, Dict, List, cast import os from warnings import warn import weakref from weakref import ReferenceType from asyncio import Future import select from collections import deque # Python 3.7 suggests get_running_loop for library code try: from asyncio import get_running_loop except ImportError: from asyncio import get_event_loop as get_running_loop from . import _ffi @contextmanager def _get_events_future(fd: int, get_function: Callable[[Union[Future, '_FakeFuture']], None]) -> Generator[Future, None, None]: '''Watch a file descriptor, call a get function, and unwatch the file descriptor. ''' event_loop = get_running_loop() future = event_loop.create_future() event_loop.add_reader(fd, get_function, future) try: yield future finally: try: event_loop.remove_reader(fd) except Exception: # The fd might already be closed; we don't want to interrupt # a CancelledError. pass class InitFlags(IntFlag): '''Init flags for use with the :class:`Inotify` constructor. You shouldn't have a reason to use this, as :attr:`CLOEXEC` will be desired because there's no reason for exec'd children to inherit inotify handles here, and :attr:`NONBLOCK` shouldn't even make a difference due to the handle always being watched with select, unless you are using synchronous mode. ''' __slots__ = () #: Set the close-on-exec (FD_CLOEXEC) flag on the new file descriptor. See #: the description of the O_CLOEXEC flag in open(2) for reasons why this #: may be useful. CLOEXEC = os.O_CLOEXEC #: Set the O_NONBLOCK file status flag on the open file description (see #: open(2)) referred to by the new file descriptor. Using this flag saves #: extra calls to fcntl(2) to achieve the same result. NONBLOCK = os.O_NONBLOCK class Mask(IntFlag): '''Bit-mask for adding a watch and for analyzing watch events. Because this is an IntFlag, all IntFlag operations work on it, such as using the bitwise or operator to combine, or using the `in` operator to check contents. ''' __slots__ = () #: No flag, to facilitate defaults ZERO = 0 #: File was accessed (e.g., read(2), execve(2)). ACCESS = 0x00000001 #: File was modified (e.g., write(2), truncate(2)). MODIFY = 0x00000002 #: Metadata changed—for example, permissions (e.g., chmod(2)), timestamps #: (e.g., utimensat(2)), extended attributes (setxattr(2)), link count #: (since Linux 2.6.25; e.g., for the target of link(2) and for unlink(2)), #: and user/group ID (e.g., chown(2)). ATTRIB = 0x00000004 #: File opened for writing was closed. CLOSE_WRITE = 0x00000008 #: File or directory not opened for writing was closed. CLOSE_NOWRITE = 0x00000010 #: :attr:`CLOSE_WRITE` | :attr:`CLOSE_NOWRITE` CLOSE = CLOSE_WRITE | CLOSE_NOWRITE #: File or directory was opened. OPEN = 0x00000020 #: Generated for the directory containing the old filename when a file is renamed. #: Note the cookie member in :class:`Event`. MOVED_FROM = 0x00000040 #: Generated for the directory containing the new filename when a file is renamed. #: Note the cookie member in :class:`Event`. MOVED_TO = 0x00000080 #: :attr:`MOVED_FROM: | :attr:`MOVED_TO` MOVE = MOVED_FROM | MOVED_TO #: File/directory created in watched directory (e.g., open(2) O_CREAT, #: mkdir(2), link(2), symlink(2), bind(2) on a UNIX domain socket). CREATE = 0x00000100 #: File/directory deleted from watched directory. DELETE = 0x00000200 #: Watched file/directory was itself deleted. (This event also occurs #: if an object is moved to another filesystem, since mv(1) in effect #: copies the file to the other filesystem and then deletes it from the #: original filesystem.) In addition, an :attr:`Mask.IGNORED` event will #: subsequently be generated for the watch descriptor. DELETE_SELF = 0x00000400 #: Watched file/directory was itself moved. MOVE_SELF = 0x00000800 #: Filesystem containing watched object was unmounted. In addition, an #: :attr:`Mask.IGNORED` event will subsequently be generated for the watch #: descriptor. UNMOUNT = 0x00002000 #: Event queue overflowed (wd is -1 for this event (:meth:`Event.watch` will be None)). Q_OVERFLOW = 0x00004000 #: Watch was removed explicitly (inotify_rm_watch(2)) or automatically #: (file was deleted, or filesystem was unmounted). IGNORED = 0x00008000 #: (since Linux 2.6.15) #: Watch pathname only if it is a directory; the error ENOTDIR results if #: pathname is not a directory. Using this flag provides an application #: with a race-free way of ensuring that the monitored object is a #: directory. ONLYDIR = 0x01000000 #: Don't dereference pathname if it is a symbolic link. DONT_FOLLOW = 0x02000000 #: By default, when watching events on the children of a directory, #: events are generated for children even after they have been unlinked #: from the directory. This can result in large numbers of uninteresting #: events for some applications (e.g., if watching /tmp, in which many #: applications create temporary files whose names are immediately #: unlinked). Specifying :attr:`Mask.EXCL_UNLINK` changes the default behavior, so #: that events are not generated for children after they have been unlinked #: from the watched directory. EXCL_UNLINK = 0x04000000 #: (since Linux 4.18) #: Watch pathname only if it does not already have a watch associated with #: it; the error EEXIST results if pathname is already being #: watched. Using this flag provides an application with a way of ensuring #: that new watches do not modify existing ones. This is useful because #: multiple paths may refer to the same inode, and multiple calls to #: inotify_add_watch(2) without this flag may clobber existing watch masks. MASK_CREATE = 0x10000000 #: If a watch instance already exists for the filesystem object #: corresponding to pathname, add (OR) the events in mask to the watch #: mask (instead of replacing the mask); the error EINVAL results if #: :attr:`Mask.MASK_CREATE` is also specified. MASK_ADD = 0x20000000 #: Subject of this event is a directory. ISDIR = 0x40000000 #: Monitor the filesystem object corresponding to pathname for one event, #: then remove from watch list. ONESHOT = 0x80000000 #: Monitor all common types of events. This does not include modifier flags like ONESHOT, MASK_CREATE, EXEC_UNLINK, etc. ALL = ACCESS | MODIFY | ATTRIB | CLOSE | OPEN | MOVE | CREATE | DELETE | DELETE_SELF | MOVE_SELF class Watch: '''Watch class. You usually won't construct this directly, but rather use :meth:`Inotify.add_watch` to create it. ''' __slots__ = ('_inotify', '_mask', '_path', '_wd', '__weakref__') def __init__(self, inotify: 'Inotify', path: Path, mask: Mask, wd: int) -> None: ''' Do not instantiate this directly. Use :meth:`Inotify.add_watch` instead. :param Inotify inotify: The :class:`Inotify` instance this Watch is being added to :param pathlib.Path path: A :class:`pathlib.Path` to the watch destination :param Mask mask: The mask for the added watch. ''' self._inotify = weakref.ref(inotify) self._mask = mask self.path = path self._wd = wd @property def inotify(self) -> Optional['Inotify']: '''The :class:`Inotify` instance this Watch belongs to. This is internally stored as a weakref, so if the :class:`Watch` outlives the :class:`Inotify`, this may return None. :returns: The :class:`Inotify` instance this Watch belongs to. ''' return self._inotify() @property def wd(self) -> int: ''' The raw watch descriptor. ''' return self._wd @property def path(self) -> Path: ''' The path that this watch is for. ''' return self._path @path.setter def path(self, value: Path) -> None: self._path = value @property def mask(self) -> Mask: ''' :returns: The mask that was used to construct this watch ''' return self._mask def __repr__(self) -> str: return ''.format(self.path, self.mask) class Event: '''Event output class. The :class:`Mask` values may be tested directly against this class. ''' __slots__ = ('_mask', '_cookie', '_name', '_watch') def __init__(self, watch: Optional[Union[Watch, ReferenceType]], mask: Mask, cookie: int, name: Optional[Path]) -> None: """Create the class. This class is internal, for all intents and purposes. Client code should have no reason to construct instances of it. :watch: A :class:`Watch` instance, a weakref, or None :mask: The mask that this event was created with :cookie: The cookie integer for identifying move operations :name: The name path. :owns_watch: Whether the event should own the watch. """ self._mask = mask self._cookie = cookie self._name = name self._watch = watch @property def watch(self) -> Optional[Watch]: '''The actual Watch instance associated with this event. This is stored internally as a weak reference. If the event is taken out of context and outlives its generating :class:`Inotify`, this may return None. If :meth:`mask` contains IGNORED or the watch was a ONESHOT, this is not a weak reference, but the actual watch instance. If the watch was ONESHOT, the corresponding IGNORED will not have a watch instance, only the ONESHOT event itself. This may be inconvenient, but the inotify man page doesn't give strong enough guarantees to risk memory leak with ONESHOT events by leaving the ownership change exclusively to IGNORED events. :returns: the watch instance that generated this ''' if self._watch is None or isinstance(self._watch, Watch): return self._watch else: return self._watch() @property def mask(self) -> Mask: '''The mask associated with this event :returns: the mask for this event ''' return self._mask @property def cookie(self) -> int: '''The cookie associated with this event. According to the `inotify man page `_, cookie is a unique integer that connects related events. Currently, this is used only for rename events, and allows the resulting pair of :attr:`Mask.MOVED_FROM` and :attr:`Mask.MOVED_TO` events to be connected by the application. For all other event types, cookie is set to 0. :returns: the cookie for this event ''' return self._cookie @property def name(self) -> Optional[Path]: '''The name associated with the event. May be None, indicating the watch directory itself. :returns: the name of the event, or None if the event is for the watch itself ''' return self._name @property def path(self) -> Optional[Path]: '''The full path to this event, constructed from the :class:`Watch` path and the :meth:`name`. If the :class:`Watch` no longer exists, this returns None. If the :meth:`name` does not exist, just returns the watch path. This value is absolute if the path used to construct the :class:`Watch` (the path used with :meth:`Inotify.add_watch`) is absolute, otherwise it is relative. This means if you have changed directory between constructing a watch with a relative path and receiving this event, you will have to have another way of identifying the file correctly. :returns: the full path for the event, or None if it can not be constructed. ''' watch = self.watch name = self.name if watch is not None: if name: return watch.path / name else: return watch.path return None def __contains__(self, value) -> bool: '''Roughly the same thing as ``value in event.mask`` ''' if isinstance(value, Mask): return value in self.mask raise TypeError("Only Mask is supported with Event's 'in' operator") def __repr__(self) -> str: return ''.format( self.name, self.mask, self.cookie, self.watch) class _FakeFuture: '''A fake future, used to support synchronous operation.''' __slots__ = ('_result',) def __init__(self) -> None: self._result: List[Event] = [] def set_result(self, value: List[Event]) -> None: self._result = value def cancelled(self) -> bool: return False @property def result(self) -> List[Event]: return self._result class Inotify: '''Core Inotify class. Fetches events in bulk, if possible, and stores them internally. Use :meth:`get` to get a single event. This class operates as an async generator, and may be asynchronously iterated, and will return events forever. :param int cache_size: The max number of full-size events to cache. The actual number may be higher, because most events will not be full-sized. :param sync_timeout: If this is not None, then sync_get will wait on an epoll call for that long, and return None on a timeout. Normal iteration will also exit on a timeout. ''' __slots__ = ('_fd', '_watches', '_events', '_epoll', '_sync_timeout', '_cache_size', '__weakref__') def __init__(self, flags: InitFlags = InitFlags.CLOEXEC | InitFlags.NONBLOCK, cache_size: int = 10, sync_timeout: Optional[float] = None) -> None: self.cache_size = cache_size fd = _ffi.libc.inotify_init1(flags) self._fd: Optional[int] = fd # Watches dict used for matching events up with the watch descriptor, # in order to get the full item path. self._watches: Dict[int, Watch] = {} self._events: List[Event] = [] self._epoll: select.epoll = select.epoll() self._epoll.register(fd, select.EPOLLIN) self.sync_timeout = sync_timeout @property def sync_timeout(self) -> Optional[float]: '''The timeout for :meth:`sync_get` and synchronous iteration. Set this to None to disable and -1 to wait forever. These options can be different depending on the blocking flags selected. ''' return self._sync_timeout @sync_timeout.setter def sync_timeout(self, value: Optional[float]) -> None: self._sync_timeout = value @property def fd(self) -> int: '''Get the raw file descriptor. ''' if self._fd is None: raise ValueError('Can not work with closed inotify') else: return self._fd def add_watch(self, path: Union[os.PathLike, bytes, str], mask: Mask) -> Watch: '''Add a watch dir. :param pathlib.Path path: a string, bytes, or PathLike object :param Mask mask: a Mask determining how the watch behaves :returns: The relevant Watch instance ''' # Convert bytes to Path if isinstance(path, bytes): bytepath = path path = Path(os.fsdecode(bytepath)) else: # HACK: For some reason, pyright is convinced that path might be a # memoryview or a bytearray here if TYPE_CHECKING: path = cast(Union[os.PathLike, str], path) # Convert non-Path to Path if not isinstance(path, Path): path = Path(path) bytepath = bytes(path) try: wd = _ffi.libc.inotify_add_watch(self.fd, bytepath, mask) except OSError as e: e.filename = path raise e # Happens for things like an existing watch instance being modified, # like MASK_ADD if wd in self._watches: watch = self._watches[wd] else: watch = Watch( inotify=self, path=path, mask=mask, wd=wd, ) self._watches[wd] = watch return watch def rm_watch(self, watch: Watch) -> None: '''Remove a watch from this inotify instance. This will generate an :attr:`Mask.IGNORED` event that contains the :class:`Watch` instance. :param Watch watch: the :class:`Watch` to remove ''' _ffi.libc.inotify_rm_watch(self.fd, watch.wd) # This does not remove from self._watches because the IGNORE event will # do that for you. def __enter__(self) -> 'Inotify': return self def __exit__(self, *args, **kwargs) -> None: self.close() def __del__(self) -> None: self.close() def close(self) -> None: '''Close the file descriptor for this inotify. Once this is done, do not do anything more with this inotify instance. Associated :class:`Watch` and :class:`Event` instances are still valid, but no more may be created, and if this :class:`Inotify` goes out of scope and is cleaned up, the :class:`Event` may lose its :class:`Watch` if you don't have a reference to it. This is automatically called when this class is used as a context manager. ''' self.sync_timeout = None if self._fd is not None: self._epoll.close() os.close(self._fd) self._fd = None @property def cache_size(self) -> int: '''The maximum number of full-sized events (events with a NAME_MAX-length name) to store. More events may be stored, because very few events should use a NAME_MAX length name.''' return self._cache_size @cache_size.setter def cache_size(self, value: int) -> None: self._cache_size = int(value) def _get(self, future: Union[Future, _FakeFuture]) -> None: '''Retrieve an array of events into an array, which is set on the passed-in future.''' buffer = BytesIO(os.read(self.fd, (_ffi.inotify_event_size + _ffi.NAME_MAX + 1) * self._cache_size)) events = [] while True: event_buffer = buffer.read(_ffi.inotify_event_size) if not event_buffer: break event_struct = _ffi.inotify_event.from_buffer_copy(event_buffer) length = event_struct.len name = None if length > 0: raw_name = buffer.read(length) zero_pos = raw_name.find(0) # If zero_pos is 0, we want name to stay None if zero_pos != 0: # If zero_pos is -1, we want the whole name string, otherwise truncate the zeros if zero_pos > 0: raw_name = raw_name[:zero_pos] name = Path(os.fsdecode(raw_name)) mask = Mask(event_struct.mask) watch: Optional[Union[Watch, ReferenceType]] = self._watches.get(event_struct.wd, None) if isinstance(watch, Watch): if Mask.IGNORED in mask or Mask.ONESHOT in watch.mask: # If IGNORED or ONESHOT, the event takes ownership of this watch del self._watches[event_struct.wd] elif watch is not None: # Otherwise, Initify retains ownership and a weak reference is created watch = weakref.ref(watch) event = Event( watch=watch, mask=mask, cookie=event_struct.cookie, name=name, ) events.append(event) if not future.cancelled(): future.set_result(events) async def get(self) -> Event: '''Get a single next event. This is the core method of event retrieval. Asynchronously iterating this class simply calls this method forever. May actually pull multiple events from the inotify handle, and store extras internally. Will always only return one. Building some events may cause changes in the associated :class:`Inotify` or :class:`Watch` instances. For instance, :attr:`Mask.IGNORE` will automatically remove its :class:`Watch` instance from this :class:`Inotify` object. A :attr:`Mask.ONESHOT` Watch will remove itself on the first event. .. caution:: A watched path being moved will cause the relevant :meth:`Watch.path` to be incorrect. This library will not automatically update it for you, because :attr:`Mask.MOVE_SELF` does not tell you the new name. You would have to watch the parent directory and change the :meth:`Watch.path` value yourself if you want that functionality. If you don't do this and the watch path is moved, the :class:`Event` will have a correct name but incorrect path. :returns: a single :class:`Event` ''' if not self._events: with _get_events_future(self.fd, self._get) as future: self._events = await future return self._events.pop(0) def sync_get(self) -> Optional[Event]: '''Get a single next event synchronously, or throw a blocking error if you've opened this in nonblocking mode. All concerns that apply to :meth:`get` also apply to this method. :returns: a single :class:`Event`, or None if sync_timeout is not None and the poll timed out. ''' if not self._events: if self.sync_timeout is not None: if not self._epoll.poll(self.sync_timeout, 1): return None future = _FakeFuture() self._get(future) self._events = future.result return self._events.pop(0) def __aiter__(self) -> "Inotify": return self def __iter__(self) -> "Inotify": return self async def __anext__(self) -> Event: """Iterate inotify events forever with :meth:`get`.""" return await self.get() def __next__(self) -> Event: """Iterate inotify events with :meth:`sync_get`. If sync_timeout is None or -1, this will iterate forever, otherwise it iterates until a timeout is reached. """ event = self.sync_get() if event is None: raise StopIteration return event @property def watches(self) -> List[Watch]: return list(self._watches.values()) class RecursiveInotify(Inotify): '''A Recursive superclass of Inotify. Adds the :meth:`add_recursive_watch` method, but otherwise works the same. Automatically adds and removes subdirectories as they are added and removed. ''' _DIR_MASK = Mask.MOVED_FROM | Mask.MOVED_TO | Mask.CREATE | Mask.IGNORED def __init__(self) -> None: super().__init__() self._mask_map: Dict[Path, Optional[Mask]] = {} def add_recursive_watch( self, path: Path, mask: Optional[Mask] = None ) -> List[Watch]: '''Add a watch for the given directory path, which must be a directory, and all subdirectories. Returns the watch for this path and all subdirectories, breadth-first (so the passed-in path is always first in the list. ''' if not path.is_dir(): raise ValueError('Path must refer to a directory') watches: List[Watch] = [] if mask is None: set_mask = self._DIR_MASK else: set_mask = mask | self._DIR_MASK watches.append(self.add_watch(path, set_mask)) self._mask_map[path] = mask for child in path.iterdir(): if child.is_dir(): watches += self.add_recursive_watch(child, mask) return watches def __enter__(self) -> "RecursiveInotify": return self def __iter__(self) -> "RecursiveInotify": return self def __aiter__(self) -> "RecursiveInotify": return self def sync_get(self) -> Optional[Event]: ev = super().sync_get() if ev is None: return if Mask.ISDIR in ev.mask: self._handle_directory_event(ev) if Mask.IGNORED in ev.mask and ev.path in self._mask_map: del self._mask_map[ev.path] return ev async def get(self) -> Event: ev = await super().get() if Mask.ISDIR in ev.mask: self._handle_directory_event(ev) if Mask.IGNORED in ev.mask and ev.path in self._mask_map: del self._mask_map[ev.path] return ev def _handle_directory_event(self, event: Event) -> None: if event.path is None: return elif event.path.parent not in self._mask_map: warn(f"Not handling directory event in non-recursive path {event.path}") elif Mask.CREATE in event.mask or Mask.MOVED_TO in event.mask: # created new folder or folder moved in, add watches mask = self._DIR_MASK stored_mask = self._mask_map.get(event.path) if stored_mask is not None: mask |= stored_mask self.add_recursive_watch(event.path, mask) elif Mask.MOVED_FROM in event.mask: event_path = PurePath(event.path) # a folder is moved to another location, remove watch # for this folder and subfolders for watch in self._watches.values(): if watch.path == event_path or event_path in watch.path.parents: self.rm_watch(watch) class RecursiveWatcher: """ watch a folder recursively: add a watch when a folder is created/moved in delete a watch when a folder is deleted/moved out this also works for folders moving within the watched folders because both move_from event and move_to event will be caught """ def __init__(self, path, mask) -> None: self._path = path self._mask = mask def _get_directories_recursive(self, path): """ DFS to iterate all paths within """ if not path.is_dir(): return stack = deque() stack.append(path) while stack: curr_path = stack.pop() yield curr_path for subpath in curr_path.iterdir(): if subpath.is_dir(): stack.append(subpath) async def watch_recursive(self, inotify=None): create_inotify = inotify is None if create_inotify: inotify = Inotify() try: mask = ( self._mask | Mask.MOVED_FROM | Mask.MOVED_TO | Mask.CREATE | Mask.IGNORED ) for directory in self._get_directories_recursive(self._path): inotify.add_watch(directory, mask) # Things that can throw this off: # # * Doing two changes on a directory or something before the program # has a time to handle it (this will also throw off a lot of inotify # code, though) # # * Trying to watch a path that doesn't exist won't automatically # create it or anything of the sort. async for event in inotify: if Mask.ISDIR in event.mask and event.path is not None: if Mask.CREATE in event.mask or Mask.MOVED_TO in event.mask: # created new folder or folder moved in, add watches for directory in self._get_directories_recursive(event.path): inotify.add_watch(directory, mask) if Mask.MOVED_FROM in event.mask: event_path = PurePath(event.path) # a folder is moved to another location, remove watch for this folder and subfolders watches = [ watch for watch in inotify._watches.values() if watch.path == event_path or event_path in watch.path.parents ] for watch in watches: inotify.rm_watch(watch) # DELETE event is not watched/handled here because IGNORED event follows deletion, and handled in asyncinotify if event.mask & self._mask: yield event finally: if create_inotify: inotify.close() ProCern-asyncinotify-320e1ee/src/asyncinotify/_ffi.py000066400000000000000000000037651510771516700230150ustar00rootroot00000000000000#!/usr/bin/env python3 # -*- coding: utf-8 -*- # This Source Code Form is subject to the terms of the Mozilla Public License, # v. 2.0. If a copy of the MPL was not distributed with this file, You can # obtain one at https://mozilla.org/MPL/2.0/. # This code is Copyright 2019 - 2023 Absolute Performance, Inc, and 2024 - 2025 # ProCern Technology Solutions. # It is written and maintained by Taylor C. Richberger import os if os.uname().sysname.lower() == 'linux' and os.environ.get('READTHEDOCS', 'false').lower() != 'true': import ctypes import ctypes.util class inotify_event(ctypes.Structure): '''FFI struct for reading inotify events. Should not be accessed externally.''' _fields_ = [ ("wd", ctypes.c_int), ("mask", ctypes.c_uint32), ("cookie", ctypes.c_uint32), ("len", ctypes.c_uint32), # name follows, and is of a variable size ] def check_return(value: int) -> int: if value == -1: errno = ctypes.get_errno() raise OSError(errno, os.strerror(errno)) return value inotify_event_size = ctypes.sizeof(inotify_event) NAME_MAX = 255 # May be None, which will work fine anyway if the program is linked dynamically # against an appropriate libc. _libcname = ctypes.util.find_library('c') libc = ctypes.CDLL(_libcname, use_errno=True) libc.inotify_init.restype = check_return libc.inotify_init.argtypes = () libc.inotify_init1.restype = check_return libc.inotify_init1.argtypes = (ctypes.c_int,) libc.inotify_add_watch.restype = check_return libc.inotify_add_watch.argtypes = (ctypes.c_int, ctypes.c_char_p, ctypes.c_uint) libc.inotify_rm_watch.restype = check_return libc.inotify_rm_watch.argtypes = (ctypes.c_int, ctypes.c_int) else: import warnings warnings.warn('inotify is a Linux-only API. You can package this library on other platforms, but not run it.') ProCern-asyncinotify-320e1ee/src/asyncinotify/py.typed000066400000000000000000000000001510771516700232120ustar00rootroot00000000000000ProCern-asyncinotify-320e1ee/test.py000077500000000000000000000714721510771516700175660ustar00rootroot00000000000000#!/usr/bin/env python3 # -*- coding: utf-8 -*- # This Source Code Form is subject to the terms of the Mozilla Public License, # v. 2.0. If a copy of the MPL was not distributed with this file, You can # obtain one at https://mozilla.org/MPL/2.0/. # This code is Copyright 2019 - 2023 Absolute Performance, Inc, and 2024 - 2025 # ProCern Technology Solutions. # It is written and maintained by Taylor C. Richberger import sys import os import shutil import unittest from pathlib import Path from tempfile import TemporaryDirectory from asyncinotify import Event, Inotify, Mask, RecursiveInotify, RecursiveWatcher if sys.version_info >= (3, 9): from collections.abc import Sequence else: from typing import Sequence import asyncio try: from asyncio import run from asyncio import create_task except ImportError: from asyncio import ensure_future as create_task def run(main): # type: ignore loop = asyncio.new_event_loop() asyncio.set_event_loop(loop) try: try: return loop.run_until_complete(main) finally: loop.run_until_complete(loop.shutdown_asyncgens()) finally: loop.close() class TestInotify(unittest.TestCase): async def watch_events(self) -> Sequence[Event]: '''Watch events until an IGNORED is received for the main watch, then return the events.''' events = [] with self.inotify as inotify: async for event in inotify: events.append(event) if Mask.IGNORED in event and event.watch is self.watch: return events raise RuntimeError() def gather_events(self, function) -> Sequence[Event]: '''Run the function "soon" in the event loop, and also watch events until you can return the result.''' try: function() finally: self.inotify.rm_watch(self.watch) return run(self.watch_events()) def setUp(self): self._dir = TemporaryDirectory() self.dir = Path(self._dir.name) self.inotify = Inotify() self.watch = self.inotify.add_watch(self.dir, Mask.ALL) def tearDown(self): self._dir.cleanup() def test_diriterated(self): def test(): list(self.dir.iterdir()) events = self.gather_events(test) self.assertTrue(all(event.watch is self.watch for event in events)) self.assertTrue(any(Mask.ISDIR|Mask.OPEN in event and event.path == self.dir for event in events)) self.assertTrue(any(Mask.ISDIR|Mask.ACCESS in event and event.path == self.dir for event in events)) self.assertTrue(any(Mask.ISDIR|Mask.CLOSE_NOWRITE in event and event.path == self.dir for event in events)) self.assertTrue(any(Mask.IGNORED in event and event.path == self.dir for event in events)) def test_foo_opened_and_closed(self): def test(): with open(self.dir / 'foo', 'w'): pass with open(self.dir / 'foo', 'r'): pass events = self.gather_events(test) self.assertTrue(all(event.watch is self.watch for event in events)) self.assertTrue(any(Mask.CREATE in event and event.path == self.dir / 'foo' for event in events)) self.assertTrue(any(Mask.OPEN in event and event.path == self.dir / 'foo' for event in events)) self.assertTrue(any(Mask.CLOSE_WRITE in event and event.path == self.dir / 'foo' for event in events)) self.assertTrue(any(Mask.CLOSE_NOWRITE in event and event.path == self.dir / 'foo' for event in events)) def test_foo_deleted(self): def test(): with open(self.dir / 'foo', 'w'): pass (self.dir / 'foo').unlink() events = self.gather_events(test) self.assertTrue(all(event.watch is self.watch for event in events)) self.assertTrue(any(Mask.DELETE in event and event.path == self.dir / 'foo' for event in events)) def test_foo_write(self): def test(): with open(self.dir / 'foo', 'w') as file: file.write('test') events = self.gather_events(test) self.assertTrue(all(event.watch is self.watch for event in events)) self.assertTrue(any(Mask.CREATE in event and event.path == self.dir / 'foo' for event in events)) self.assertTrue(any(Mask.OPEN in event and event.path == self.dir / 'foo' for event in events)) self.assertTrue(any(Mask.MODIFY in event and event.path == self.dir / 'foo' for event in events)) self.assertTrue(any(Mask.CLOSE_WRITE in event and event.path == self.dir / 'foo' for event in events)) def test_foo_moved(self): def test(): with open(self.dir / 'foo', 'w'): pass (self.dir / 'foo').rename(self.dir / 'bar') events = self.gather_events(test) self.assertTrue(all(event.watch is self.watch for event in events)) self.assertTrue(any(Mask.MOVED_FROM in event and event.path == self.dir / 'foo' for event in events)) self.assertTrue(any(Mask.MOVED_TO in event and event.path == self.dir / 'bar' for event in events)) self.assertEqual( next(event.cookie for event in events if Mask.MOVED_FROM in event), next(event.cookie for event in events if Mask.MOVED_TO in event), ) def test_foo_attrib(self): def test(): with open(self.dir / 'foo', 'w'): pass (self.dir / 'foo').chmod(0o777) events = self.gather_events(test) self.assertTrue(all(event.watch is self.watch for event in events)) self.assertTrue(any(Mask.ATTRIB in event and event.path == self.dir / 'foo' for event in events)) def test_onlydir_error(self): with open(self.dir / 'foo', 'w'): pass # Will not raise error self.inotify.add_watch(self.dir / 'foo', Mask.ATTRIB) with self.assertRaises(OSError): self.inotify.add_watch(self.dir / 'foo', Mask.ATTRIB | Mask.ONLYDIR) def test_nonexist_error(self): with self.assertRaises(OSError): self.inotify.add_watch(self.dir / 'foo', Mask.ATTRIB | Mask.ONLYDIR) with self.assertRaises(OSError): self.inotify.add_watch(self.dir / 'foo', Mask.ATTRIB) def test_move_self(self): with open(self.dir / 'foo', 'w'): pass watch = self.inotify.add_watch(self.dir / 'foo', Mask.MOVE_SELF) def test(): (self.dir / 'foo').rename(self.dir / 'bar') events = self.gather_events(test) self.assertTrue(any(Mask.MOVE_SELF in event and event.path == self.dir / 'foo' and event.watch is watch for event in events)) def test_delete_self(self): with open(self.dir / 'foo', 'w'): pass watch = self.inotify.add_watch(self.dir / 'foo', Mask.DELETE_SELF) def test(): (self.dir / 'foo').unlink() events = self.gather_events(test) self.assertTrue(any(Mask.DELETE_SELF in event and event.path == self.dir / 'foo' and event.watch is watch for event in events)) self.assertTrue(any(Mask.IGNORED in event and event.path == self.dir / 'foo' and event.watch is watch for event in events)) self.assertTrue(any(Mask.IGNORED in event and event.path == self.dir for event in events)) def test_oneshot(self): with open(self.dir / 'foo', 'w'): pass watch = self.inotify.add_watch(self.dir / 'foo', Mask.CREATE | Mask.OPEN | Mask.ONESHOT) def test(): with open(self.dir / 'foo', 'r'): pass (self.dir / 'foo').unlink() events = self.gather_events(test) # We check for name is None because only the first event will have a watch value self.assertTrue(any(Mask.OPEN in event and event.name is None and event.path == self.dir / 'foo' and event.watch is watch for event in events)) # The oneshot has already expired, so this should not exist self.assertFalse(any(Mask.DELETE in event and event.name is None for event in events)) # There may or may not be an IGNORED for the watch as well class TestSyncInotify(unittest.TestCase): def watch_events(self) -> Sequence[Event]: '''Watch events until an IGNORED is received for the main watch, then return the events.''' events = [] with self.inotify as inotify: for event in inotify: events.append(event) if Mask.IGNORED in event and event.watch is self.watch: return events raise RuntimeError() def gather_events(self, function) -> Sequence[Event]: '''Run the function and then watch events until you can return the result.''' try: function() finally: self.inotify.rm_watch(self.watch) return self.watch_events() def setUp(self): self._dir = TemporaryDirectory() self.dir = Path(self._dir.name) self.inotify = Inotify() self.watch = self.inotify.add_watch(self.dir, Mask.ACCESS | Mask.MODIFY | Mask.ATTRIB | Mask.CLOSE_WRITE | Mask.CLOSE_NOWRITE | Mask.OPEN | Mask.MOVED_FROM | Mask.MOVED_TO | Mask.CREATE | Mask.DELETE | Mask.DELETE_SELF | Mask.MOVE_SELF) def tearDown(self): self._dir.cleanup() def test_diriterated(self): def test(): list(self.dir.iterdir()) events = self.gather_events(test) self.assertTrue(all(event.watch is self.watch for event in events)) self.assertTrue(any(Mask.ISDIR|Mask.OPEN in event and event.path == self.dir for event in events)) self.assertTrue(any(Mask.ISDIR|Mask.ACCESS in event and event.path == self.dir for event in events)) self.assertTrue(any(Mask.ISDIR|Mask.CLOSE_NOWRITE in event and event.path == self.dir for event in events)) self.assertTrue(any(Mask.IGNORED in event and event.path == self.dir for event in events)) def test_foo_opened_and_closed(self): def test(): with open(self.dir / 'foo', 'w'): pass with open(self.dir / 'foo', 'r'): pass events = self.gather_events(test) self.assertTrue(all(event.watch is self.watch for event in events)) self.assertTrue(any(Mask.CREATE in event and event.path == self.dir / 'foo' for event in events)) self.assertTrue(any(Mask.OPEN in event and event.path == self.dir / 'foo' for event in events)) self.assertTrue(any(Mask.CLOSE_WRITE in event and event.path == self.dir / 'foo' for event in events)) self.assertTrue(any(Mask.CLOSE_NOWRITE in event and event.path == self.dir / 'foo' for event in events)) def test_foo_deleted(self): def test(): with open(self.dir / 'foo', 'w'): pass (self.dir / 'foo').unlink() events = self.gather_events(test) self.assertTrue(all(event.watch is self.watch for event in events)) self.assertTrue(any(Mask.DELETE in event and event.path == self.dir / 'foo' for event in events)) def test_foo_write(self): def test(): with open(self.dir / 'foo', 'w') as file: file.write('test') events = self.gather_events(test) self.assertTrue(all(event.watch is self.watch for event in events)) self.assertTrue(any(Mask.CREATE in event and event.path == self.dir / 'foo' for event in events)) self.assertTrue(any(Mask.OPEN in event and event.path == self.dir / 'foo' for event in events)) self.assertTrue(any(Mask.MODIFY in event and event.path == self.dir / 'foo' for event in events)) self.assertTrue(any(Mask.CLOSE_WRITE in event and event.path == self.dir / 'foo' for event in events)) def test_foo_moved(self): def test(): with open(self.dir / 'foo', 'w'): pass (self.dir / 'foo').rename(self.dir / 'bar') events = self.gather_events(test) self.assertTrue(all(event.watch is self.watch for event in events)) self.assertTrue(any(Mask.MOVED_FROM in event and event.path == self.dir / 'foo' for event in events)) self.assertTrue(any(Mask.MOVED_TO in event and event.path == self.dir / 'bar' for event in events)) self.assertEqual( next(event.cookie for event in events if Mask.MOVED_FROM in event), next(event.cookie for event in events if Mask.MOVED_TO in event), ) def test_foo_attrib(self): def test(): with open(self.dir / 'foo', 'w'): pass (self.dir / 'foo').chmod(0o777) events = self.gather_events(test) self.assertTrue(all(event.watch is self.watch for event in events)) self.assertTrue(any(Mask.ATTRIB in event and event.path == self.dir / 'foo' for event in events)) def test_onlydir_error(self): with open(self.dir / 'foo', 'w'): pass # Will not raise error self.inotify.add_watch(self.dir / 'foo', Mask.ATTRIB) with self.assertRaises(OSError): self.inotify.add_watch(self.dir / 'foo', Mask.ATTRIB | Mask.ONLYDIR) def test_nonexist_error(self): with self.assertRaises(OSError): self.inotify.add_watch(self.dir / 'foo', Mask.ATTRIB | Mask.ONLYDIR) with self.assertRaises(OSError): self.inotify.add_watch(self.dir / 'foo', Mask.ATTRIB) def test_move_self(self): with open(self.dir / 'foo', 'w'): pass watch = self.inotify.add_watch(self.dir / 'foo', Mask.MOVE_SELF) def test(): (self.dir / 'foo').rename(self.dir / 'bar') events = self.gather_events(test) self.assertTrue(any(Mask.MOVE_SELF in event and event.path == self.dir / 'foo' and event.watch is watch for event in events)) def test_delete_self(self): with open(self.dir / 'foo', 'w'): pass watch = self.inotify.add_watch(self.dir / 'foo', Mask.DELETE_SELF) def test(): (self.dir / 'foo').unlink() events = self.gather_events(test) self.assertTrue(any(Mask.DELETE_SELF in event and event.path == self.dir / 'foo' and event.watch is watch for event in events)) self.assertTrue(any(Mask.IGNORED in event and event.path == self.dir / 'foo' and event.watch is watch for event in events)) self.assertTrue(any(Mask.IGNORED in event and event.path == self.dir for event in events)) def test_oneshot(self): with open(self.dir / 'foo', 'w'): pass watch = self.inotify.add_watch(self.dir / 'foo', Mask.CREATE | Mask.OPEN | Mask.ONESHOT) def test(): with open(self.dir / 'foo', 'r'): pass (self.dir / 'foo').unlink() events = self.gather_events(test) # We check for name is None because only the first event will have a watch value self.assertTrue(any(Mask.OPEN in event and event.name is None and event.path == self.dir / 'foo' and event.watch is watch for event in events)) # The oneshot has already expired, so this should not exist self.assertFalse(any(Mask.DELETE in event and event.name is None for event in events)) # There may or may not be an IGNORED for the watch as well def test_timeout(self): with self.inotify as inotify: inotify.sync_timeout = 0.1 list(self.dir.iterdir()) self.assertTrue(inotify.sync_get()) for event in inotify: pass self.assertFalse(inotify.sync_get()) class TestInotifyRepeat(unittest.TestCase): async def _actual_test(self): events: list[Event] = [] async def loop(n): async for event in n: events.append(event) with TemporaryDirectory() as dir: path = Path(dir) / 'file.txt' path.touch() with Inotify() as n: n.add_watch(path, Mask.ACCESS | Mask.MODIFY | Mask.OPEN | Mask.CREATE | Mask.DELETE | Mask.ATTRIB | Mask.DELETE | Mask.DELETE_SELF | Mask.CLOSE | Mask.MOVE) task = create_task(loop(n)) await asyncio.sleep(0.1) with path.open('w'): pass await asyncio.sleep(0.1) task.cancel() with Inotify() as n: n.add_watch(path, Mask.ACCESS | Mask.MODIFY | Mask.OPEN | Mask.CREATE | Mask.DELETE | Mask.ATTRIB | Mask.DELETE | Mask.DELETE_SELF | Mask.CLOSE | Mask.MOVE) task = create_task(loop(n)) await asyncio.sleep(0.1) path.unlink() await asyncio.sleep(0.1) task.cancel() self.assertTrue(any(Mask.OPEN in event for event in events)) self.assertTrue(any(Mask.CLOSE_WRITE in event for event in events)) self.assertTrue(any(Mask.DELETE_SELF in event for event in events)) def test_events(self): run(self._actual_test()) class TestRecursiveInotify(unittest.TestCase): def setUp(self): self._dir = TemporaryDirectory() self.dir = Path(self._dir.name) def tearDown(self): self._dir.cleanup() def test_recursive_watch_adds_existing_subdirs(self): with RecursiveInotify() as inotify: subdir = self.dir / "recursive" subdir.mkdir() watches = inotify.add_recursive_watch(path=self.dir) self.assertEqual(watches[0].path, self.dir) self.assertEqual(watches[1].path, subdir) def test_recursive_watch_adds_new_subdirs(self): with RecursiveInotify() as inotify: inotify.add_recursive_watch(path=self.dir) subdir = self.dir / "recursive" subdir.mkdir() ev = next(inotify) self.assertIsNotNone(ev) # need an explicit assert for static type checking as pyright does # not narrow the scope of ev with `assertIsNotNone` assert ev is not None self.assertIsNotNone(ev.watch) assert ev.watch is not None self.assertEqual(ev.watch.path, subdir.parent) self.assertEqual(ev.name, subdir.relative_to(self.dir)) self.assertIn(ev.watch, inotify.watches) def test_recursive_watch_removes_subdirs(self): with RecursiveInotify() as inotify: subdir = self.dir / "recursive" subdir.mkdir() inotify.add_recursive_watch(path=self.dir) subdir.rmdir() ev = next(inotify) self.assertNotIn(ev.watch, inotify.watches) def test_normal_watch_does_not_add_subdirs(self): with RecursiveInotify() as inotify: inotify.add_watch(path=self.dir, mask=Mask.ALL) subdir = self.dir / "recursive" subdir.mkdir() self.assertNotIn(subdir, [w.path for w in inotify.watches]) class TestRecursiveWatcher(unittest.TestCase): def test_get_directories_recursive(self): """ create folder tree as: level1.1 -level2.1 -level3.1 -level4.1 -level2.2 level1.2 """ with TemporaryDirectory() as tmpdirname: tmpdir = Path(tmpdirname) (tmpdir / 'level1.1' / 'level2.1' / 'level3.1' / 'level4.1').mkdir(parents=True, exist_ok=True) (tmpdir / 'level1.1' / 'level2.2').mkdir(parents=True, exist_ok=True) (tmpdir / 'level1.2').mkdir(parents=True, exist_ok=True) watcher = RecursiveWatcher(None, None) paths = [path for path in watcher._get_directories_recursive(Path(tmpdirname))] self.assertEqual(set(paths), { tmpdir, Path(tmpdirname) / "level1.2", Path(tmpdirname) / "level1.1", Path(tmpdirname) / "level1.1" / "level2.2", Path(tmpdirname) / "level1.1" / "level2.1", Path(tmpdirname) / "level1.1" / "level2.1" / "level3.1", Path(tmpdirname) / "level1.1" / "level2.1" / "level3.1" / "level4.1", }) def _assert_paths_watched(self, watchers, path_set): watched_path_set = {str(watch.path) for watch in watchers.values()} self.assertSetEqual(watched_path_set, path_set) class _FakeWatcher: def __init__(self, path) -> None: self.path = path def test_assert_paths_watched(self): # both empty self._assert_paths_watched({}, set()) # watchers empty with self.assertRaises(AssertionError): self._assert_paths_watched({}, {"/tmp/path1"}) # path set empty with self.assertRaises(AssertionError): self._assert_paths_watched({ "fd1": self._FakeWatcher(Path("/tmp/path1")), "fd2": self._FakeWatcher(Path("/tmp/path2")), }, set()) # identical sets self._assert_paths_watched({ "fd1": self._FakeWatcher(Path("/tmp/path1")), "fd2": self._FakeWatcher(Path("/tmp/path2")), }, { "/tmp/path2", "/tmp/path1" }) # diff sets with self.assertRaises(AssertionError): self._assert_paths_watched({ "fd1": self._FakeWatcher(Path("/tmp/path1")), }, { "/tmp/path2", "/tmp/path1" }) def _create_file(self, file_path): with open(str(file_path), "w") as f: f.write(file_path) async def _read_events(self, inotify, folder, events): watcher = RecursiveWatcher(Path(folder), Mask.CLOSE_WRITE) async for event in watcher.watch_recursive(inotify): # events/watchers are ephemeral, copy data we want events.append(( event.path, event.mask, )) async def _watch_recursive(self): """ test the cases of folder changes: 1. create folder 2. create cascading folders 3. move folder in from un-monitored folder 4. move folders out to un-monitored folder 5. move folder within monitored folders 6. delete folders """ with TemporaryDirectory() as tmpdirbasename: events = [] tmpdirname = os.path.join(tmpdirbasename, "test") os.makedirs(tmpdirname) existing_dir = os.path.join(tmpdirname, "existing_dir") os.makedirs(existing_dir) outside_dir = os.path.join(tmpdirbasename, "outside") os.makedirs(outside_dir) with Inotify() as inotify: watch_task = create_task(self._read_events(inotify, tmpdirname, events)) await asyncio.sleep(0.3) # existing 2 folders are watched self._assert_paths_watched(inotify._watches, { tmpdirname, existing_dir, }) # create file, event file_path = os.path.join(tmpdirname, "f1.txt") self._create_file(file_path) await asyncio.sleep(0.3) # still 2 folders watched self._assert_paths_watched(inotify._watches, { tmpdirname, existing_dir, }) # create folder and a file inside, no event because of racing folder_path = os.path.join(tmpdirname, "d1") os.makedirs(folder_path) file_path = os.path.join(folder_path, "f2.txt") self._create_file(file_path) await asyncio.sleep(0.3) # one more folder watched self._assert_paths_watched(inotify._watches, { tmpdirname, existing_dir, os.path.join(tmpdirname, "d1"), }) # create cascade folders folder_path = os.path.join(tmpdirname, "d2", "dd1", "ddd1") os.makedirs(folder_path) await asyncio.sleep(0.3) # 3 more folders watched self._assert_paths_watched(inotify._watches, { tmpdirname, existing_dir, os.path.join(tmpdirname, "d1"), os.path.join(tmpdirname, "d2"), os.path.join(tmpdirname, "d2", "dd1"), os.path.join(tmpdirname, "d2", "dd1", "ddd1"), }) # move in folder from outside move_folder_path = os.path.join(tmpdirname, "d1", "outside") os.rename(outside_dir, move_folder_path) await asyncio.sleep(0.3) # one more folder watched self._assert_paths_watched(inotify._watches, { tmpdirname, existing_dir, os.path.join(tmpdirname, "d1"), os.path.join(tmpdirname, "d2"), os.path.join(tmpdirname, "d2", "dd1"), os.path.join(tmpdirname, "d2", "dd1", "ddd1"), os.path.join(tmpdirname, "d1", "outside"), }) # create file in watched outside folder, event file_path = os.path.join(tmpdirname, "d1", "outside", "f3.txt") self._create_file(file_path) await asyncio.sleep(0.3) # move out folder folder_path = os.path.join(tmpdirname, "d2", "dd1") move_folder_path = os.path.join(tmpdirbasename, "dd1") os.rename(folder_path, move_folder_path) await asyncio.sleep(0.3) # 2 folders not watched self._assert_paths_watched(inotify._watches, { tmpdirname, existing_dir, os.path.join(tmpdirname, "d1"), os.path.join(tmpdirname, "d2"), os.path.join(tmpdirname, "d1", "outside"), }) # create file in not watched folder, no event file_path = os.path.join(tmpdirbasename, "dd1", "ddd1", "f4.txt") self._create_file(file_path) await asyncio.sleep(0.3) # move folder within folder_path = os.path.join(tmpdirname, "existing_dir") move_folder_path = os.path.join(tmpdirname, "d1", "existing_dir") os.rename(folder_path, move_folder_path) await asyncio.sleep(0.3) # folders change self._assert_paths_watched(inotify._watches, { tmpdirname, os.path.join(tmpdirname, "d1"), os.path.join(tmpdirname, "d2"), os.path.join(tmpdirname, "d1", "outside"), os.path.join(tmpdirname, "d1", "existing_dir") }) # create file in moved folder, event file_path = os.path.join(tmpdirname, "d1", "existing_dir", "f5.txt") self._create_file(file_path) await asyncio.sleep(0.3) # delete folder folder_path = os.path.join(tmpdirname, "d2") os.removedirs(folder_path) await asyncio.sleep(0.3) # one less folder watched self._assert_paths_watched(inotify._watches, { tmpdirname, os.path.join(tmpdirname, "d1"), os.path.join(tmpdirname, "d1", "outside"), os.path.join(tmpdirname, "d1", "existing_dir") }) # delete folders shutil.rmtree(os.path.join(tmpdirname, "d1")) await asyncio.sleep(0.3) # less folders watched self._assert_paths_watched(inotify._watches, { tmpdirname, }) watch_task.cancel() await asyncio.gather(watch_task, return_exceptions=True) # verify events self.assertEqual(len(events), 3) self.assertEqual(str(events[0][0]), os.path.join(tmpdirname, "f1.txt")) self.assertTrue(events[0][1] & Mask.CLOSE_WRITE) self.assertEqual(str(events[1][0]), os.path.join(tmpdirname, "d1", "outside", "f3.txt")) self.assertTrue(events[1][1] & Mask.CLOSE_WRITE) self.assertEqual(str(events[2][0]), os.path.join(tmpdirname, "d1", "existing_dir", "f5.txt")) self.assertTrue(events[2][1] & Mask.CLOSE_WRITE) def test_watch_recursive(self): run(self._watch_recursive()) if __name__ == '__main__': unittest.main() ProCern-asyncinotify-320e1ee/uv.lock000066400000000000000000000001661510771516700175260ustar00rootroot00000000000000version = 1 revision = 3 requires-python = ">=3.6, <4" [[package]] name = "asyncinotify" source = { editable = "." }