pax_global_header00006660000000000000000000000064145122365160014517gustar00rootroot0000000000000052 comment=6ea19cac0447b3594cca29793fe0d308c3585a6a python-watchfiles-0.21.0/000077500000000000000000000000001451223651600152475ustar00rootroot00000000000000python-watchfiles-0.21.0/.cargo/000077500000000000000000000000001451223651600164205ustar00rootroot00000000000000python-watchfiles-0.21.0/.cargo/config.toml000066400000000000000000000004401451223651600205600ustar00rootroot00000000000000# see https://pyo3.rs/main/building_and_distribution.html#macos [target.x86_64-apple-darwin] rustflags = [ "-C", "link-arg=-undefined", "-C", "link-arg=dynamic_lookup", ] [target.aarch64-apple-darwin] rustflags = [ "-C", "link-arg=-undefined", "-C", "link-arg=dynamic_lookup", ] python-watchfiles-0.21.0/.codecov.yml000066400000000000000000000002231451223651600174670ustar00rootroot00000000000000coverage: precision: 2 range: [90, 100] status: patch: false project: false comment: layout: 'header, diff, flags, files, footer' python-watchfiles-0.21.0/.github/000077500000000000000000000000001451223651600166075ustar00rootroot00000000000000python-watchfiles-0.21.0/.github/FUNDING.yml000066400000000000000000000000251451223651600204210ustar00rootroot00000000000000github: samuelcolvin python-watchfiles-0.21.0/.github/ISSUE_TEMPLATE/000077500000000000000000000000001451223651600207725ustar00rootroot00000000000000python-watchfiles-0.21.0/.github/ISSUE_TEMPLATE/bug.yml000066400000000000000000000047521451223651600223020ustar00rootroot00000000000000name: Bug description: Report a bug or unexpected behavior in watchfiles labels: [bug] body: - type: markdown attributes: value: | Thanks for your interest in watchfiles! 😎 Please provide as much detail as possible to make understanding and solving your problem as quick as possible. 🙏 - type: textarea id: description attributes: label: Description description: Please explain what you're seeing and what you expect to see. - type: textarea id: example attributes: label: Example Code description: | Please add a self-contained, [minimal, reproducible, example](https://stackoverflow.com/help/minimal-reproducible-example) with your use case. **Alternatively**, you might find it useful to run ```bash watchfiles 'echo reloaded' . --verbose ``` and copy the output below. placeholder: | import watchfiles print(f'Watchfiles v{watchfiles.__version__}') for changes in watchfiles.watch('.', debug=True): print(changes) render: Python - type: textarea id: output attributes: label: Watchfiles Output description: Output from the above example code or `watchfiles 'echo reloaded' . --verbose` render: Text - type: textarea id: os attributes: label: Operating System & Architecture description: | What operating system version and system architecture are you using? ```bash python -c 'import platform; print(platform.platform()); print(platform.version())' ``` validations: required: true - type: input id: env attributes: label: Environment description: > Are you using a specific environment like docker, WSL, etc.? Also if you installed watchfiles or python in an unusual way. - type: input id: python-watchfiles-version attributes: label: Python & Watchfiles Version description: | Which version Python and Watchfiles are you using? ```bash python -c 'import sys, watchfiles; print(f"python: {sys.version}, watchfiles: {watchfiles.__version__}")' ``` validations: required: true - type: input id: rust-version attributes: label: Rust & Cargo Version description: | If you're building watchfiles locally, which Rust and Cargo version are you using? ```bash cargo --version rustc --version ``` python-watchfiles-0.21.0/.github/set_version.py000077500000000000000000000025231451223651600215260ustar00rootroot00000000000000#!/usr/bin/env python3 import os import re import sys from pathlib import Path def main(cargo_path_env_var='CARGO_PATH', version_env_vars=('VERSION', 'GITHUB_REF')) -> int: cargo_path = os.getenv(cargo_path_env_var, 'Cargo.toml') cargo_path = Path(cargo_path) if not cargo_path.is_file(): print(f'✖ path "{cargo_path}" does not exist') return 1 version = None for var in version_env_vars: version_ref = os.getenv(var) if version_ref: version = re.sub('^refs/tags/v*', '', version_ref.lower()) break if not version: print(f'✖ "{version_env_vars}" env variables not found') return 1 # convert from python pre-release version to rust pre-release version # this is the reverse of what's done in lib.rs::_rust_notify version = version.replace('a', '-alpha').replace('b', '-beta') print(f'writing version "{version}", to {cargo_path}') version_regex = re.compile('^version ?= ?".*"', re.M) cargo_content = cargo_path.read_text() if not version_regex.search(cargo_content): print(f'✖ {version_regex!r} not found in {cargo_path}') return 1 new_content = version_regex.sub(f'version = "{version}"', cargo_content) cargo_path.write_text(new_content) return 0 if __name__ == '__main__': sys.exit(main()) python-watchfiles-0.21.0/.github/workflows/000077500000000000000000000000001451223651600206445ustar00rootroot00000000000000python-watchfiles-0.21.0/.github/workflows/ci.yml000066400000000000000000000173751451223651600217770ustar00rootroot00000000000000name: ci on: push: branches: - main tags: - '**' pull_request: {} jobs: test: name: test ${{ matrix.python-version }}, rust ${{ matrix.rust-version }} on ${{ matrix.os }} strategy: fail-fast: false matrix: os: [ubuntu, macos, windows] rust-version: [stable, '1.56.0'] python-version: - '3.8' - '3.9' - '3.10' - '3.11' - '3.12' - 'pypy3.8' - 'pypy3.9' - 'pypy3.10' exclude: - rust-version: '1.56.0' os: macos - rust-version: '1.56.0' os: windows runs-on: ${{ matrix.os }}-latest env: PYTHON: ${{ matrix.python-version }} RUST: ${{ matrix.rust-version }} OS: ${{ matrix.os }} steps: - uses: actions/checkout@v3 - name: set up python uses: actions/setup-python@v4 with: python-version: ${{ matrix.python-version }} - name: install rust uses: actions-rs/toolchain@v1 with: profile: minimal toolchain: ${{ matrix.rust-version }} override: true - name: cache rust uses: Swatinem/rust-cache@v1 - run: pip install -r requirements/pyproject.txt -r requirements/testing.txt - run: pip install -e . - run: pip freeze - if: matrix.os == 'ubuntu' run: | mkdir -p ${{ github.workspace }}/protected touch ${{ github.workspace }}/protected/test sudo chown -R root:root ${{ github.workspace }}/protected sudo chmod 700 ${{ github.workspace }}/protected - run: make test env: WATCHFILES_TEST_PERMISSION_DENIED_PATH: ${{ github.workspace }}/protected - run: coverage xml - uses: codecov/codecov-action@v1.0.13 with: file: ./coverage.xml env_vars: PYTHON,RUST,OS lint: runs-on: ubuntu-latest steps: - uses: actions/checkout@v3 - uses: actions/setup-python@v4 with: python-version: '3.10' - name: install rust uses: actions-rs/toolchain@v1 with: profile: minimal toolchain: stable override: true components: rustfmt, clippy - name: cache rust uses: Swatinem/rust-cache@v1 - run: pip install -r requirements/pyproject.txt -r requirements/linting.txt - run: pip install -e . - uses: pre-commit/action@v3.0.0 with: extra_args: --all-files docs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v3 - name: set up python uses: actions/setup-python@v4 with: python-version: '3.10' - name: install run: pip install -r requirements/docs.txt - name: build site run: mkdocs build - name: store docs site uses: actions/upload-artifact@v3 with: name: docs path: site build: name: > build on ${{ matrix.platform || matrix.os }} (${{ matrix.target }} - ${{ matrix.manylinux || 'auto' }}) if: "startsWith(github.ref, 'refs/tags/') || github.ref == 'refs/heads/main' || contains(github.event.pull_request.labels.*.name, 'Full Build')" strategy: fail-fast: false matrix: os: [ubuntu, macos, windows] target: [x86_64, aarch64] manylinux: [auto] include: - os: ubuntu platform: linux pypy: true - os: macos target: x86_64 pypy: true - os: macos target: aarch64 pypy: true - os: windows ls: dir - os: windows ls: dir target: i686 python-architecture: x86 - os: windows ls: dir target: aarch64 interpreter: 3.11 3.12 - os: ubuntu platform: linux target: i686 - os: ubuntu platform: linux target: armv7 - os: ubuntu platform: linux target: ppc64le - os: ubuntu platform: linux target: s390x # musllinux - os: ubuntu platform: linux target: x86_64 manylinux: musllinux_1_1 - os: ubuntu platform: linux target: aarch64 manylinux: musllinux_1_1 runs-on: ${{ matrix.os }}-latest steps: - uses: actions/checkout@v3 - name: set up python uses: actions/setup-python@v4 with: python-version: '3.10' architecture: ${{ matrix.python-architecture || 'x64' }} - name: set package version run: python .github/set_version.py if: "startsWith(github.ref, 'refs/tags/')" - name: Sync Cargo.lock run: cargo update -p watchfiles_rust_notify if: "startsWith(github.ref, 'refs/tags/')" - name: build sdist if: ${{ matrix.os == 'ubuntu' && matrix.target == 'x86_64' && matrix.manylinux == 'auto' }} uses: messense/maturin-action@v1 with: command: sdist args: --out dist - name: build wheels uses: messense/maturin-action@v1 with: target: ${{ matrix.target }} manylinux: ${{ matrix.manylinux || 'auto' }} args: --release --out dist --interpreter ${{ matrix.interpreter || '3.8 3.9 3.10 3.11 3.12' }} - name: build pypy wheels if: ${{ matrix.pypy }} uses: messense/maturin-action@v1 with: target: ${{ matrix.target }} manylinux: ${{ matrix.manylinux || 'auto' }} args: --release --out dist --interpreter pypy3.8 pypy3.9 pypy3.10 - run: ${{ matrix.ls || 'ls -lh' }} dist/ - uses: actions/upload-artifact@v3 with: name: pypi_files path: dist list-pypi-files: needs: [build] runs-on: ubuntu-latest steps: - uses: actions/checkout@v3 - name: get dist artifacts uses: actions/download-artifact@v3 with: name: pypi_files path: dist - name: list dist files run: | ls -lh dist/ echo "`ls dist | wc -l` files" - name: extract and list sdist file run: | mkdir sdist-files tar -xvf dist/*.tar.gz -C sdist-files tree -a sdist-files - name: extract and list wheel file run: | ls dist/*cp37-abi3-manylinux*x86_64.whl | head -n 1 python -m zipfile --list `ls dist/*cp37-abi3-manylinux*x86_64.whl | head -n 1` - run: pip install twine - run: twine check dist/* # Used for branch protection checks, see https://github.com/marketplace/actions/alls-green#why check: if: always() needs: [test, lint, docs] runs-on: ubuntu-latest steps: - name: Decide whether the needed jobs succeeded or failed uses: re-actors/alls-green@release/v1 with: jobs: ${{ toJSON(needs) }} release: needs: [build, check, docs] if: "success() && startsWith(github.ref, 'refs/tags/')" runs-on: ubuntu-latest steps: - uses: actions/checkout@v3 - name: set up python uses: actions/setup-python@v4 with: python-version: '3.10' - run: pip install twine - name: get dist artifacts uses: actions/download-artifact@v3 with: name: pypi_files path: dist - name: get docs uses: actions/download-artifact@v3 with: name: docs path: site - run: twine check dist/* - name: upload to pypi run: twine upload dist/* env: TWINE_USERNAME: __token__ TWINE_PASSWORD: ${{ secrets.pypi_token }} - name: publish docs uses: JamesIves/github-pages-deploy-action@v4.2.5 with: branch: gh-pages folder: site python-watchfiles-0.21.0/.github/workflows/upload-previews.yml000066400000000000000000000016251451223651600245210ustar00rootroot00000000000000name: Upload Previews on: workflow_run: workflows: [ci] types: [completed] permissions: statuses: write jobs: upload-previews: if: ${{ github.event.workflow_run.conclusion == 'success' }} runs-on: ubuntu-latest steps: - uses: actions/setup-python@v1 with: python-version: '3.10' - run: pip install click==8.0.4 - run: pip install smokeshow - uses: dawidd6/action-download-artifact@v2 with: workflow: ci.yml commit: ${{ github.event.workflow_run.head_sha }} - run: smokeshow upload docs env: SMOKESHOW_GITHUB_STATUS_DESCRIPTION: Docs Preview SMOKESHOW_GITHUB_CONTEXT: docs SMOKESHOW_GITHUB_TOKEN: ${{ secrets.github_token }} SMOKESHOW_GITHUB_PR_HEAD_SHA: ${{ github.event.workflow_run.head_sha }} SMOKESHOW_AUTH_KEY: ${{ secrets.smokeshow_auth_key }} python-watchfiles-0.21.0/.gitignore000066400000000000000000000004211451223651600172340ustar00rootroot00000000000000/.idea/ /env/ /env*/ *.py[cod] *.egg-info/ build/ dist/ .cache/ .mypy_cache/ test.py .coverage htmlcov/ docs/_build/ /.pytest_cache/ /sandbox/ /foobar.py /watchfiles/*.so /target/ /site/ # i have a pet hate of the proliferation of files in the root directory .rustfmt.toml python-watchfiles-0.21.0/.pre-commit-config.yaml000066400000000000000000000010601451223651600215250ustar00rootroot00000000000000repos: - repo: https://github.com/pre-commit/pre-commit-hooks rev: v4.0.1 hooks: - id: check-yaml args: ['--unsafe'] - id: check-toml - id: end-of-file-fixer - id: trailing-whitespace - id: check-added-large-files - repo: local hooks: - id: lint-python name: Lint Python entry: make lint-python types: [python] language: system - id: lint-rust name: Lint Rust entry: make lint-rust types: [rust] language: system - id: mypy name: Mypy entry: make mypy types: [python] language: system python-watchfiles-0.21.0/Cargo.lock000066400000000000000000000314271451223651600171630ustar00rootroot00000000000000# This file is automatically @generated by Cargo. # It is not intended for manual editing. version = 3 [[package]] name = "autocfg" version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d468802bab17cbc0cc575e9b053f41e72aa36bfa6b7f55e3529ffa43161b97fa" [[package]] name = "bitflags" version = "1.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" [[package]] name = "cc" version = "1.0.83" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f1174fb0b6ec23863f8b971027804a42614e347eafb0a95bf0b12cdae21fc4d0" dependencies = [ "libc", ] [[package]] name = "cfg-if" version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd" [[package]] name = "crossbeam-channel" version = "0.5.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cf2b3e8478797446514c91ef04bafcb59faba183e621ad488df88983cc14128c" dependencies = [ "cfg-if", "crossbeam-utils", ] [[package]] name = "crossbeam-utils" version = "0.8.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3c063cd8cc95f5c377ed0d4b49a4b21f632396ff690e8470c29b3359b346984b" dependencies = [ "cfg-if", ] [[package]] name = "filetime" version = "0.2.20" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8a3de6e8d11b22ff9edc6d916f890800597d60f8b2da1caf2955c274638d6412" dependencies = [ "cfg-if", "libc", "redox_syscall", "windows-sys 0.45.0", ] [[package]] name = "fsevent-sys" version = "4.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "76ee7a02da4d231650c7cea31349b889be2f45ddb3ef3032d2ec8185f6313fd2" dependencies = [ "libc", ] [[package]] name = "heck" version = "0.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "95505c38b4572b2d910cecb0281560f54b440a19336cbbcb27bf6ce6adc6f5a8" [[package]] name = "indoc" version = "2.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1e186cfbae8084e513daff4240b4797e342f988cecda4fb6c939150f96315fd8" [[package]] name = "inotify" version = "0.9.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8069d3ec154eb856955c1c0fbffefbf5f3c40a104ec912d4797314c1801abff" dependencies = [ "bitflags", "inotify-sys", "libc", ] [[package]] name = "inotify-sys" version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e05c02b5e89bff3b946cedeca278abc628fe811e604f027c45a8aa3cf793d0eb" dependencies = [ "libc", ] [[package]] name = "kqueue" version = "1.0.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2c8fc60ba15bf51257aa9807a48a61013db043fcf3a78cb0d916e8e396dcad98" dependencies = [ "kqueue-sys", "libc", ] [[package]] name = "kqueue-sys" version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8367585489f01bc55dd27404dcf56b95e6da061a256a666ab23be9ba96a2e587" dependencies = [ "bitflags", "libc", ] [[package]] name = "libc" version = "0.2.140" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "99227334921fae1a979cf0bfdfcc6b3e5ce376ef57e16fb6fb3ea2ed6095f80c" [[package]] name = "lock_api" version = "0.4.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "435011366fe56583b16cf956f9df0095b405b82d76425bc8981c0e22e60ec4df" dependencies = [ "autocfg", "scopeguard", ] [[package]] name = "log" version = "0.4.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "abb12e687cfb44aa40f41fc3978ef76448f9b6038cad6aef4259d3c095a2382e" dependencies = [ "cfg-if", ] [[package]] name = "memoffset" version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5a634b1c61a95585bd15607c6ab0c4e5b226e695ff2800ba0cdccddf208c406c" dependencies = [ "autocfg", ] [[package]] name = "mio" version = "0.8.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5b9d9a46eff5b4ff64b45a9e316a6d1e0bc719ef429cbec4dc630684212bfdf9" dependencies = [ "libc", "log", "wasi", "windows-sys 0.45.0", ] [[package]] name = "notify" version = "5.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "58ea850aa68a06e48fdb069c0ec44d0d64c8dbffa49bf3b6f7f0a901fdea1ba9" dependencies = [ "bitflags", "crossbeam-channel", "filetime", "fsevent-sys", "inotify", "kqueue", "libc", "mio", "walkdir", "windows-sys 0.42.0", ] [[package]] name = "once_cell" version = "1.17.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b7e5500299e16ebb147ae15a00a942af264cf3688f47923b8fc2cd5858f23ad3" [[package]] name = "parking_lot" version = "0.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3742b2c103b9f06bc9fff0a37ff4912935851bee6d36f3c02bcc755bcfec228f" dependencies = [ "lock_api", "parking_lot_core", ] [[package]] name = "parking_lot_core" version = "0.9.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9069cbb9f99e3a5083476ccb29ceb1de18b9118cafa53e90c9551235de2b9521" dependencies = [ "cfg-if", "libc", "redox_syscall", "smallvec", "windows-sys 0.45.0", ] [[package]] name = "proc-macro2" version = "1.0.53" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ba466839c78239c09faf015484e5cc04860f88242cff4d03eb038f04b4699b73" dependencies = [ "unicode-ident", ] [[package]] name = "pyo3" version = "0.20.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "04e8453b658fe480c3e70c8ed4e3d3ec33eb74988bd186561b0cc66b85c3bc4b" dependencies = [ "cfg-if", "indoc", "libc", "memoffset", "parking_lot", "pyo3-build-config", "pyo3-ffi", "pyo3-macros", "unindent", ] [[package]] name = "pyo3-build-config" version = "0.20.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a96fe70b176a89cff78f2fa7b3c930081e163d5379b4dcdf993e3ae29ca662e5" dependencies = [ "once_cell", "python3-dll-a", "target-lexicon", ] [[package]] name = "pyo3-ffi" version = "0.20.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "214929900fd25e6604661ed9cf349727c8920d47deff196c4e28165a6ef2a96b" dependencies = [ "libc", "pyo3-build-config", ] [[package]] name = "pyo3-macros" version = "0.20.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dac53072f717aa1bfa4db832b39de8c875b7c7af4f4a6fe93cdbf9264cf8383b" dependencies = [ "proc-macro2", "pyo3-macros-backend", "quote", "syn", ] [[package]] name = "pyo3-macros-backend" version = "0.20.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7774b5a8282bd4f25f803b1f0d945120be959a36c72e08e7cd031c792fdfd424" dependencies = [ "heck", "proc-macro2", "quote", "syn", ] [[package]] name = "python3-dll-a" version = "0.2.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d5f07cd4412be8fa09a721d40007c483981bbe072cd6a21f2e83e04ec8f8343f" dependencies = [ "cc", ] [[package]] name = "quote" version = "1.0.26" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4424af4bf778aae2051a77b60283332f386554255d722233d09fbfc7e30da2fc" dependencies = [ "proc-macro2", ] [[package]] name = "redox_syscall" version = "0.2.16" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fb5a58c1855b4b6819d59012155603f0b22ad30cad752600aadfcb695265519a" dependencies = [ "bitflags", ] [[package]] name = "same-file" version = "1.0.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" dependencies = [ "winapi-util", ] [[package]] name = "scopeguard" version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d29ab0c6d3fc0ee92fe66e2d99f700eab17a8d57d1c1d3b748380fb20baa78cd" [[package]] name = "smallvec" version = "1.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a507befe795404456341dfab10cef66ead4c041f62b8b11bbb92bffe5d0953e0" [[package]] name = "syn" version = "2.0.12" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "79d9531f94112cfc3e4c8f5f02cb2b58f72c97b7efd85f70203cc6d8efda5927" dependencies = [ "proc-macro2", "quote", "unicode-ident", ] [[package]] name = "target-lexicon" version = "0.12.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8ae9980cab1db3fceee2f6c6f643d5d8de2997c58ee8d25fb0cc8a9e9e7348e5" [[package]] name = "unicode-ident" version = "1.0.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e5464a87b239f13a63a501f2701565754bae92d243d4bb7eb12f6d57d2269bf4" [[package]] name = "unindent" version = "0.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c7de7d73e1754487cb58364ee906a499937a0dfabd86bcb980fa99ec8c8fa2ce" [[package]] name = "walkdir" version = "2.3.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "36df944cda56c7d8d8b7496af378e6b16de9284591917d307c9b4d313c44e698" dependencies = [ "same-file", "winapi-util", ] [[package]] name = "wasi" version = "0.11.0+wasi-snapshot-preview1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9c8d87e72b64a3b4db28d11ce29237c246188f4f51057d65a7eab63b7987e423" [[package]] name = "watchfiles_rust_notify" version = "0.0.0" dependencies = [ "crossbeam-channel", "notify", "pyo3", ] [[package]] name = "winapi" version = "0.3.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" dependencies = [ "winapi-i686-pc-windows-gnu", "winapi-x86_64-pc-windows-gnu", ] [[package]] name = "winapi-i686-pc-windows-gnu" version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" [[package]] name = "winapi-util" version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "70ec6ce85bb158151cae5e5c87f95a8e97d2c0c4b001223f33a334e3ce5de178" dependencies = [ "winapi", ] [[package]] name = "winapi-x86_64-pc-windows-gnu" version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" [[package]] name = "windows-sys" version = "0.42.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5a3e1820f08b8513f676f7ab6c1f99ff312fb97b553d30ff4dd86f9f15728aa7" dependencies = [ "windows_aarch64_gnullvm", "windows_aarch64_msvc", "windows_i686_gnu", "windows_i686_msvc", "windows_x86_64_gnu", "windows_x86_64_gnullvm", "windows_x86_64_msvc", ] [[package]] name = "windows-sys" version = "0.45.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "75283be5efb2831d37ea142365f009c02ec203cd29a3ebecbc093d52315b66d0" dependencies = [ "windows-targets", ] [[package]] name = "windows-targets" version = "0.42.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8e5180c00cd44c9b1c88adb3693291f1cd93605ded80c250a75d472756b4d071" dependencies = [ "windows_aarch64_gnullvm", "windows_aarch64_msvc", "windows_i686_gnu", "windows_i686_msvc", "windows_x86_64_gnu", "windows_x86_64_gnullvm", "windows_x86_64_msvc", ] [[package]] name = "windows_aarch64_gnullvm" version = "0.42.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "597a5118570b68bc08d8d59125332c54f1ba9d9adeedeef5b99b02ba2b0698f8" [[package]] name = "windows_aarch64_msvc" version = "0.42.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e08e8864a60f06ef0d0ff4ba04124db8b0fb3be5776a5cd47641e942e58c4d43" [[package]] name = "windows_i686_gnu" version = "0.42.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c61d927d8da41da96a81f029489353e68739737d3beca43145c8afec9a31a84f" [[package]] name = "windows_i686_msvc" version = "0.42.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "44d840b6ec649f480a41c8d80f9c65108b92d89345dd94027bfe06ac444d1060" [[package]] name = "windows_x86_64_gnu" version = "0.42.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8de912b8b8feb55c064867cf047dda097f92d51efad5b491dfb98f6bbb70cb36" [[package]] name = "windows_x86_64_gnullvm" version = "0.42.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "26d41b46a36d453748aedef1486d5c7a85db22e56aff34643984ea85514e94a3" [[package]] name = "windows_x86_64_msvc" version = "0.42.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9aec5da331524158c6d1a4ac0ab1541149c0b9505fde06423b02f5ef0106b9f0" python-watchfiles-0.21.0/Cargo.toml000066400000000000000000000013031451223651600171740ustar00rootroot00000000000000[package] name = "watchfiles_rust_notify" version = "0.0.0" edition = "2021" license = "MIT" homepage = "https://github.com/samuelcolvin/watchfiles/watchfiles" repository = "https://github.com/samuelcolvin/watchfiles/watchfiles.git" readme = "README.md" include = [ "/pyproject.toml", "/README.md", "/LICENSE", "/Makefile", "/src", "/watchfiles", "/tests", "/requirements", "/.cargo", "!__pycache__", "!tests/.mypy_cache", "!tests/.pytest_cache", "!*.so", ] [dependencies] crossbeam-channel = "0.5.4" notify = "5.0.0" pyo3 = {version = "=0.20", features = ["extension-module", "generate-import-lib"]} [lib] name = "_rust_notify" crate-type = ["cdylib"] python-watchfiles-0.21.0/LICENSE000066400000000000000000000021261451223651600162550ustar00rootroot00000000000000The MIT License (MIT) Copyright (c) 2017, 2018, 2019, 2020, 2021, 2022 Samuel Colvin Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. python-watchfiles-0.21.0/Makefile000066400000000000000000000027401451223651600167120ustar00rootroot00000000000000.DEFAULT_GOAL := all isort = isort watchfiles tests black = black watchfiles tests ruff = ruff watchfiles tests .PHONY: install install: pip install -U pip pre-commit pip install -r requirements/all.txt pip install -e . pre-commit install .PHONY: build-dev build-dev: pip uninstall -y watchfiles @rm -f watchfiles/*.so cargo build @rm -f target/debug/lib_rust_notify.d @mv target/debug/lib_rust_notify.* watchfiles/_rust_notify.so .PHONY: format format: $(ruff) --fix-only $(isort) $(black) @echo 'max_width = 120' > .rustfmt.toml cargo fmt .PHONY: lint-python lint-python: $(ruff) $(isort) --check-only --df $(black) --check --diff .PHONY: lint-rust lint-rust: cargo fmt --version @echo 'max_width = 120' > .rustfmt.toml cargo fmt --all -- --check cargo clippy --version cargo clippy -- -D warnings .PHONY: lint lint: lint-python lint-rust .PHONY: mypy mypy: mypy watchfiles .PHONY: test test: coverage run -m pytest .PHONY: testcov testcov: test @echo "building coverage html" @coverage html .PHONY: docs docs: rm -f watchfiles/*.so mkdocs build .PHONY: all all: lint mypy testcov docs .PHONY: clean clean: rm -rf `find . -name __pycache__` rm -f `find . -type f -name '*.py[co]' ` rm -f `find . -type f -name '*.so' ` rm -f `find . -type f -name '*~' ` rm -f `find . -type f -name '.*~' ` rm -f tests/__init__.py rm -rf .cache rm -rf htmlcov rm -rf .pytest_cache rm -rf .mypy_cache rm -rf *.egg-info rm -f .coverage rm -f .coverage.* rm -rf build python-watchfiles-0.21.0/README.md000066400000000000000000000060661451223651600165360ustar00rootroot00000000000000# watchfiles [![CI](https://github.com/samuelcolvin/watchfiles/workflows/ci/badge.svg?event=push)](https://github.com/samuelcolvin/watchfiles/actions?query=event%3Apush+branch%3Amain+workflow%3Aci) [![Coverage](https://codecov.io/gh/samuelcolvin/watchfiles/branch/main/graph/badge.svg)](https://codecov.io/gh/samuelcolvin/watchfiles) [![pypi](https://img.shields.io/pypi/v/watchfiles.svg)](https://pypi.python.org/pypi/watchfiles) [![CondaForge](https://img.shields.io/conda/v/conda-forge/watchfiles.svg)](https://anaconda.org/conda-forge/watchfiles) [![license](https://img.shields.io/github/license/samuelcolvin/watchfiles.svg)](https://github.com/samuelcolvin/watchfiles/blob/main/LICENSE) Simple, modern and high performance file watching and code reload in python. --- **Documentation**: [watchfiles.helpmanual.io](https://watchfiles.helpmanual.io) **Source Code**: [github.com/samuelcolvin/watchfiles](https://github.com/samuelcolvin/watchfiles) --- Underlying file system notifications are handled by the [Notify](https://github.com/notify-rs/notify) rust library. This package was previously named "watchgod", see [the migration guide](https://watchfiles.helpmanual.io/migrating/) for more information. ## Installation **watchfiles** requires Python 3.8 - 3.12. ```bash pip install watchfiles ``` Binaries are available for: * **Linux**: `x86_64`, `aarch64`, `i686`, `armv7l`, `musl-x86_64` & `musl-aarch64` * **MacOS**: `x86_64` & `arm64` * **Windows**: `amd64` & `win32` Otherwise, you can install from source which requires Rust stable to be installed. ## Usage Here are some examples of what **watchfiles** can do: ### `watch` Usage ```py from watchfiles import watch for changes in watch('./path/to/dir'): print(changes) ``` See [`watch` docs](https://watchfiles.helpmanual.io/api/watch/#watchfiles.watch) for more details. ### `awatch` Usage ```py import asyncio from watchfiles import awatch async def main(): async for changes in awatch('/path/to/dir'): print(changes) asyncio.run(main()) ``` See [`awatch` docs](https://watchfiles.helpmanual.io/api/watch/#watchfiles.awatch) for more details. ### `run_process` Usage ```py from watchfiles import run_process def foobar(a, b, c): ... if __name__ == '__main__': run_process('./path/to/dir', target=foobar, args=(1, 2, 3)) ``` See [`run_process` docs](https://watchfiles.helpmanual.io/api/run_process/#watchfiles.run_process) for more details. ### `arun_process` Usage ```py import asyncio from watchfiles import arun_process def foobar(a, b, c): ... async def main(): await arun_process('./path/to/dir', target=foobar, args=(1, 2, 3)) if __name__ == '__main__': asyncio.run(main()) ``` See [`arun_process` docs](https://watchfiles.helpmanual.io/api/run_process/#watchfiles.arun_process) for more details. ## CLI **watchfiles** also comes with a CLI for running and reloading code. To run `some command` when files in `src` change: ``` watchfiles "some command" src ``` For more information, see [the CLI docs](https://watchfiles.helpmanual.io/cli/). Or run ```bash watchfiles --help ``` python-watchfiles-0.21.0/docs/000077500000000000000000000000001451223651600161775ustar00rootroot00000000000000python-watchfiles-0.21.0/docs/CNAME000066400000000000000000000000311451223651600167370ustar00rootroot00000000000000watchfiles.helpmanual.io python-watchfiles-0.21.0/docs/api/000077500000000000000000000000001451223651600167505ustar00rootroot00000000000000python-watchfiles-0.21.0/docs/api/filters.md000066400000000000000000000031621451223651600207440ustar00rootroot00000000000000All classes described here are designed to be used for the `watch_filter` argument to the [`watch`][watchfiles.watch] function and similar. This argument requires a simple callable which takes two arguments (the [`Change`][watchfiles.Change] type and the path as a string) and returns a boolean indicating if the change should be included (`True`) or ignored (`False`). As shown below in [Custom Filters](#custom-filters), you can either a `BaseFilter` subclass instance or your own callable. ::: watchfiles.BaseFilter rendering: merge_init_into_class: false ::: watchfiles.DefaultFilter ::: watchfiles.PythonFilter ## Custom Filters Here's an example of a custom filter which extends `DefaultFilter` to only notice changes to common web files: ```python from watchfiles import Change, DefaultFilter, watch class WebFilter(DefaultFilter): allowed_extensions = '.html', '.css', '.js' def __call__(self, change: Change, path: str) -> bool: return ( super().__call__(change, path) and path.endswith(self.allowed_extensions) ) for changes in watch('my/web/project', watch_filter=WebFilter()): print (changes) ``` Here's an example of a customer filter which is a simple callable that ignores changes unless they represent a new file being created: ```py from watchfiles import Change, watch def only_added(change: Change, path: str) -> bool: return change == Change.added for changes in watch('my/project', watch_filter=only_added): print (changes) ``` For more details, see [`filters.py`](https://github.com/samuelcolvin/watchfiles/blob/main/watchfiles/filters.py). python-watchfiles-0.21.0/docs/api/run_process.md000066400000000000000000000001401451223651600216270ustar00rootroot00000000000000 ::: watchfiles.run_process ::: watchfiles.arun_process ::: watchfiles.run.detect_target_type python-watchfiles-0.21.0/docs/api/rust_backend.md000066400000000000000000000016061451223651600217410ustar00rootroot00000000000000::: watchfiles._rust_notify.RustNotify ::: watchfiles._rust_notify.WatchfilesRustInternalError ::: watchfiles._rust_notify.__version__ # Rust backend direct usage The rust backend can be accessed directly as follows: ```py title="Rust backend example" from watchfiles._rust_notify import RustNotify r = RustNotify(['first/path', 'second/path'], False, False, 0, True, False) changes = r.watch(1_600, 50, 100, None) print(changes) r.close() ``` Or using `RustNotify` as a context manager: ```py title="Rust backend context manager example" from watchfiles._rust_notify import RustNotify with RustNotify(['first/path', 'second/path'], False, False, 0, True, False) as r: changes = r.watch(1_600, 50, 100, None) print(changes) ``` (See the documentation on [`close`][watchfiles._rust_notify.RustNotify.close] above for when and why the context manager or `close` method are required.) python-watchfiles-0.21.0/docs/api/watch.md000066400000000000000000000001431451223651600203760ustar00rootroot00000000000000::: watchfiles.watch ::: watchfiles.awatch ::: watchfiles.Change ::: watchfiles.main.FileChange python-watchfiles-0.21.0/docs/cli.md000066400000000000000000000037631451223651600173010ustar00rootroot00000000000000*watchfiles* comes with a CLI for running and reloading code, the CLI uses [watchfiles.run_process][watchfiles.run_process] to run the code and like `run_process` can either run a python function or a shell-like command. The CLI can be used either via `watchfiles ...` or `python -m watchfiles ...`. ## Running and restarting a python function Let's say you have `foobar.py` (in this case a very simple web server using [aiohttp](https://aiohttp.readthedocs.io/en/stable/)) which gets details about recent file changes from the `WATCHFILES_CHANGES` environment variable (see [`run_process` docs](./api/run_process.md#watchfiles.run_process)) and returns them as JSON. ```py title="foobar.py" import os, json from aiohttp import web async def handle(request): # get the most recent file changes and return them changes = os.getenv('WATCHFILES_CHANGES') changes = json.loads(changes) return web.json_response(dict(changes=changes)) app = web.Application() app.router.add_get('/', handle) def main(): web.run_app(app, port=8000) ``` You could run this and reload it when any file in the current directory changes with: ```bash title="Running a python function" watchfiles foobar.main ``` ## Running and restarting a command Let's say you want to re-run failing tests whenever files change. You could do this with **watchfiles** using ```bash title="Running a command" watchfiles 'pytest --lf' ``` (pytest's `--lf` option is a shortcut for `--last-failed`, see [pytest docs](https://docs.pytest.org/en/latest/how-to/cache.html)) By default the CLI will watch the current directory and all subdirectories, but the directory/directories watched can be changed. In this example, we might want to watch only the `src` and `tests` directories, and only react to changes in python files: ```bash title="Watching custom directories and files" watchfiles --filter python 'pytest --lf' src tests ``` ## Help Run `watchfiles --help` for more options. ```{title="watchfiles --help"} {! docs/cli_help.txt !} ``` python-watchfiles-0.21.0/docs/cli_help.txt000066400000000000000000000052561451223651600205270ustar00rootroot00000000000000usage: watchfiles [-h] [--ignore-paths [IGNORE_PATHS]] [--target-type [{command,function,auto}]] [--filter [FILTER]] [--args [ARGS]] [--verbose] [--non-recursive] [--verbosity [{warning,info,debug}]] [--sigint-timeout [SIGINT_TIMEOUT]] [--grace-period [GRACE_PERIOD]] [--sigkill-timeout [SIGKILL_TIMEOUT]] [--ignore-permission-denied] [--version] target [paths ...] Watch one or more directories and execute either a shell command or a python function on file changes. Example of watching the current directory and calling a python function: watchfiles foobar.main Example of watching python files in two local directories and calling a shell command: watchfiles --filter python 'pytest --lf' src tests See https://watchfiles.helpmanual.io/cli/ for more information. positional arguments: target Command or dotted function path to run paths Filesystem paths to watch, defaults to current directory options: -h, --help show this help message and exit --ignore-paths [IGNORE_PATHS] Specify directories to ignore, to ignore multiple paths use a comma as separator, e.g. "env" or "env,node_modules" --target-type [{command,function,auto}] Whether the target should be intercepted as a shell command or a python function, defaults to "auto" which infers the target type from the target string --filter [FILTER] Which files to watch, defaults to "default" which uses the "DefaultFilter", "python" uses the "PythonFilter", "all" uses no filter, any other value is interpreted as a python function/class path which is imported --args [ARGS] Arguments to set on sys.argv before calling target function, used only if the target is a function --verbose Set log level to "debug", wins over `--verbosity` --non-recursive Do not watch for changes in sub-directories recursively --verbosity [{warning,info,debug}] Log level, defaults to "info" --sigint-timeout [SIGINT_TIMEOUT] How long to wait for the sigint timeout before sending sigkill. --grace-period [GRACE_PERIOD] Number of seconds after the process is started before watching for changes. --sigkill-timeout [SIGKILL_TIMEOUT] How long to wait for the sigkill timeout before issuing a timeout exception. --ignore-permission-denied Ignore permission denied errors while watching files and directories. --version, -V show program's version number and exit python-watchfiles-0.21.0/docs/index.md000066400000000000000000000067551451223651600176450ustar00rootroot00000000000000# watchfiles [![CI](https://github.com/samuelcolvin/watchfiles/workflows/ci/badge.svg?event=push)](https://github.com/samuelcolvin/watchfiles/actions?query=event%3Apush+branch%3Amain+workflow%3Aci) [![Coverage](https://codecov.io/gh/samuelcolvin/watchfiles/branch/main/graph/badge.svg)](https://codecov.io/gh/samuelcolvin/watchfiles) [![pypi](https://img.shields.io/pypi/v/watchfiles.svg)](https://pypi.python.org/pypi/watchfiles) [![license](https://img.shields.io/github/license/samuelcolvin/watchfiles.svg)](https://github.com/samuelcolvin/watchfiles/blob/main/LICENSE) {{ version }}. Simple, modern and high performance file watching and code reload in python. Underlying file system notifications are handled by the [Notify](https://github.com/notify-rs/notify) rust library. This package was previously named "watchgod", see [Migrating from watchgod](./migrating.md) for more information. ## Usage Here are some examples of what **watchfiles** can do: ```py title="watch Usage" from watchfiles import watch for changes in watch('./path/to/dir'): print(changes) ``` See [`watch` docs][watchfiles.watch] for more details. `watch` (and all other methods) can watch either files or directories and can watch more than one path with a single instance. ```py title="awatch Usage" import asyncio from watchfiles import awatch async def main(): async for changes in awatch('/path/to/dir'): print(changes) asyncio.run(main()) ``` See [`awatch` docs][watchfiles.awatch] for more details. ```py title="run_process Usage" from watchfiles import run_process def foobar(a, b, c): ... if __name__ == '__main__': run_process('./path/to/dir', target=foobar, args=(1, 2, 3)) ``` See [`run_process` docs][watchfiles.run_process] for more details. ```py title="arun_process Usage" import asyncio from watchfiles import arun_process def foobar(a, b, c): ... async def main(): await arun_process('./path/to/dir', target=foobar, args=(1, 2, 3)) if __name__ == '__main__': asyncio.run(main()) ``` See [`arun_process` docs][watchfiles.arun_process] for more details. ## Installation **watchfiles** requires **Python 3.7** to **Python 3.10**. ### From PyPI Using `pip`: ```bash pip install watchfiles ``` Binaries are available for: * **Linux**: `x86_64`, `aarch64`, `i686`, `armv7l`, `musl-x86_64` & `musl-aarch64` * **MacOS**: `x86_64` & `arm64` (except python 3.7) * **Windows**: `amd64` & `win32` ### From conda-forge Using `conda` or `mamba`: ```bash mamba install -c conda-forge watchfiles ``` Binaries are available for: * **Linux**: `x86_64` * **MacOS**: `x86_64` & `arm64` (except python 3.7) * **Windows**: `amd64` ### From source You can also install from source which requires Rust stable to be installed. ## How Watchfiles Works **watchfiles** is based on the [Notify](https://github.com/notify-rs/notify) rust library. All the hard work of integrating with the OS's file system events notifications and falling back to polling is palmed off onto the rust library. "Debouncing" changes - e.g. grouping changes into batches rather than firing a yield/reload for each file changed is managed in rust. The rust code takes care of creating a new thread to watch for file changes so in the case of the synchronous methods (`watch` and `run_process`) no threading logic is required in python. When using the asynchronous methods (`awatch` and `arun_process`) [`anyio.to_thread.run_sync`](https://anyio.readthedocs.io/en/stable/api.html#anyio.to_thread.run_sync) is used to wait for changes in a thread. python-watchfiles-0.21.0/docs/migrating.md000066400000000000000000000031331451223651600205020ustar00rootroot00000000000000This package was significantly rewritten and renamed from `watchgod` to `watchfiles`, these docs refer to the new `watchfiles` package. The main reason for this change was to avoid confusion with the similarly named "watchdog" package, see [#102](https://github.com/samuelcolvin/watchfiles/issues/102) for more details. The most significant code change was to switch from file scanning/polling to OS file system notifications using the [Notify](https://github.com/notify-rs/notify) rust library. This is much more performant than the old approach. As a result, the external interface to the library has been changed somewhat. The main methods: * [`watch`][watchfiles.watch] * [`awatch`][watchfiles.awatch] * [`run_process`][watchfiles.run_process] * [`arun_process`][watchfiles.arun_process] All remain, the following changes affect them all: * `watcher_cls` is removed and replaced by `watch_filter` which should be a simple callable, see [filter docs](./api/filters.md) * all these methods allow multiple paths to be watched, as result, the `target` argument to `run_process` & `arun_process` is now keyword-only * the other optional keyword arguments have changed somewhat, mostly as a result of cleanup, all public methods are now thoroughly documented ## The old `watchgod` package remains The old `watchgod` [pypi package](https://pypi.org/project/watchgod/) remains, I'll add a notice about the new package name, but otherwise It'll continue to work (in future, it might get deprecation warnings). Documentation is available in [the old README](https://github.com/samuelcolvin/watchfiles/tree/watchgod). python-watchfiles-0.21.0/docs/plugins.py000066400000000000000000000031601451223651600202320ustar00rootroot00000000000000import logging import os import re from mkdocs.config import Config from mkdocs.structure.files import Files from mkdocs.structure.pages import Page logger = logging.getLogger('mkdocs.plugin') def on_pre_build(config: Config): """ Not doing anything here anymore. """ pass def on_files(files: Files, config: Config) -> Files: return remove_files(files) def remove_files(files: Files) -> Files: to_remove = [] for file in files: if file.src_path in {'plugins.py', 'cli_help.txt'}: to_remove.append(file) elif file.src_path.startswith('__pycache__/'): to_remove.append(file) logger.debug('removing files: %s', [f.src_path for f in to_remove]) for f in to_remove: files.remove(f) return files def on_page_markdown(markdown: str, page: Page, config: Config, files: Files) -> str: markdown = reinstate_code_titles(markdown) return add_version(markdown, page) def reinstate_code_titles(markdown: str) -> str: """ Fix titles in code blocks, see https://youtrack.jetbrains.com/issue/PY-53246. """ return re.sub(r'^(```py)\s*\ntitle=', r'\1 title=', markdown, flags=re.M) def add_version(markdown: str, page: Page) -> str: if page.abs_url == '/': version_ref = os.getenv('GITHUB_REF') if version_ref: version = re.sub('^refs/tags/', '', version_ref.lower()) version_str = f'Documentation for version: **{version}**' else: version_str = 'Documentation for development version' markdown = re.sub(r'{{ *version *}}', version_str, markdown) return markdown python-watchfiles-0.21.0/mkdocs.yml000066400000000000000000000041551451223651600172570ustar00rootroot00000000000000site_name: watchfiles site_description: Simple, modern and high performance file watching and code reload in python. site_url: https://watchfiles.helpmanual.io theme: name: material palette: - scheme: default primary: blue grey accent: indigo toggle: icon: material/lightbulb name: Switch to dark mode - scheme: slate primary: blue grey accent: indigo toggle: icon: material/lightbulb-outline name: Switch to light mode features: - search.suggest - search.highlight - content.tabs.link - content.code.annotate icon: repo: fontawesome/brands/github-alt # logo: img/logo-white.svg # favicon: img/favicon.png language: en repo_name: samuelcolvin/watchfiles repo_url: https://github.com/samuelcolvin/watchfiles edit_uri: '' nav: - Introduction: index.md - CLI: cli.md - 'Migration from watchgod': migrating.md - 'API Documentation': - api/watch.md - api/run_process.md - api/filters.md - api/rust_backend.md markdown_extensions: - toc: permalink: true - admonition - pymdownx.details - pymdownx.superfences - pymdownx.highlight: anchor_linenums: true - pymdownx.inlinehilite - pymdownx.snippets - attr_list - md_in_html - mdx_include - pymdownx.emoji: emoji_index: !!python/name:materialx.emoji.twemoji emoji_generator: !!python/name:materialx.emoji.to_svg extra: social: - icon: fontawesome/brands/github-alt link: https://github.com/samuelcolvin/watchfiles - icon: fontawesome/brands/twitter link: https://twitter.com/samuel_colvin plugins: - search - mkdocstrings: watch: [watchfiles] handlers: python: rendering: show_root_heading: true show_root_full_path: false show_source: false heading_level: 2 merge_init_into_class: true show_signature_annotations: true separate_signature: true - mkdocs-simple-hooks: hooks: on_pre_build: 'docs.plugins:on_pre_build' on_files: 'docs.plugins:on_files' on_page_markdown: 'docs.plugins:on_page_markdown' python-watchfiles-0.21.0/pyproject.toml000066400000000000000000000051701451223651600201660ustar00rootroot00000000000000[build-system] requires = ['maturin>=0.14.16,<2'] build-backend = 'maturin' [project] name = 'watchfiles' requires-python = '>=3.8' description = 'Simple, modern and high performance file watching and code reload in python.' authors = [ {name ='Samuel Colvin', email = 's@muelcolvin.com'}, ] dependencies = [ 'anyio>=3.0.0', ] classifiers = [ 'Development Status :: 5 - Production/Stable', 'Environment :: Console', 'Programming Language :: Python', 'Programming Language :: Python :: 3', 'Programming Language :: Python :: 3 :: Only', 'Programming Language :: Python :: 3.8', 'Programming Language :: Python :: 3.9', 'Programming Language :: Python :: 3.10', 'Programming Language :: Python :: 3.11', 'Programming Language :: Python :: 3.12', 'Intended Audience :: Developers', 'Intended Audience :: Information Technology', 'Intended Audience :: System Administrators', 'License :: OSI Approved :: MIT License', 'Operating System :: POSIX :: Linux', 'Operating System :: Microsoft :: Windows', 'Operating System :: MacOS', 'Environment :: MacOS X', 'Topic :: Software Development :: Libraries :: Python Modules', 'Topic :: System :: Filesystems', 'Framework :: AnyIO', ] dynamic = [ 'license', 'readme', 'version' ] [project.scripts] watchfiles = 'watchfiles.cli:cli' [project.urls] Homepage = 'https://github.com/samuelcolvin/watchfiles' Documentation = 'https://watchfiles.helpmanual.io' Funding = 'https://github.com/sponsors/samuelcolvin' Source = 'https://github.com/samuelcolvin/watchfiles' Changelog = 'https://github.com/samuelcolvin/watchfiles/releases' [tool.maturin] module-name = "watchfiles._rust_notify" bindings = 'pyo3' [tool.pytest.ini_options] testpaths = 'tests' log_format = '%(name)s %(levelname)s: %(message)s' filterwarnings = 'error' timeout = 10 [tool.coverage.run] source = ['watchfiles'] branch = true [tool.coverage.report] precision = 2 exclude_lines = [ 'pragma: no cover', 'raise NotImplementedError', 'raise NotImplemented', 'if TYPE_CHECKING:', '@overload', ] omit = ['*/__main__.py'] [tool.black] color = true line-length = 120 target-version = ['py39'] skip-string-normalization = true [tool.isort] line_length = 120 multi_line_output = 3 include_trailing_comma = true force_grid_wrap = 0 combine_as_imports = true color_output = true [tool.ruff] line-length = 120 extend-select = ['Q'] flake8-quotes = {inline-quotes = 'single', multiline-quotes = 'double'} [tool.mypy] strict = true warn_return_any = false show_error_codes = true [[tool.mypy.overrides]] module = ['trio.*'] ignore_missing_imports = true python-watchfiles-0.21.0/requirements/000077500000000000000000000000001451223651600177725ustar00rootroot00000000000000python-watchfiles-0.21.0/requirements/all.txt000066400000000000000000000001031451223651600212750ustar00rootroot00000000000000-r ./pyproject.txt -r ./docs.txt -r ./linting.txt -r ./testing.txt python-watchfiles-0.21.0/requirements/docs.in000066400000000000000000000001221451223651600212450ustar00rootroot00000000000000black mkdocs mkdocs-material mkdocs-simple-hooks mkdocstrings[python] mdx-include python-watchfiles-0.21.0/requirements/docs.txt000066400000000000000000000035251451223651600214700ustar00rootroot00000000000000# # This file is autogenerated by pip-compile with python 3.10 # To update, run: # # pip-compile --output-file=requirements/docs.txt requirements/docs.in # black==22.8.0 # via -r docs.in click==8.1.3 # via # black # mkdocs cyclic==1.0.0 # via mdx-include ghp-import==2.1.0 # via mkdocs griffe==0.22.0 # via mkdocstrings-python importlib-metadata==4.12.0 # via mkdocs jinja2==3.1.2 # via # mkdocs # mkdocs-material # mkdocstrings markdown==3.3.7 # via # mdx-include # mkdocs # mkdocs-autorefs # mkdocs-material # mkdocstrings # pymdown-extensions markupsafe==2.1.1 # via # jinja2 # mkdocstrings mdx-include==1.4.2 # via -r docs.in mergedeep==1.3.4 # via mkdocs mkdocs==1.3.1 # via # -r docs.in # mkdocs-autorefs # mkdocs-material # mkdocs-simple-hooks # mkdocstrings mkdocs-autorefs==0.4.1 # via mkdocstrings mkdocs-material==8.4.3 # via -r docs.in mkdocs-material-extensions==1.0.3 # via mkdocs-material mkdocs-simple-hooks==0.1.5 # via -r docs.in mkdocstrings[python]==0.19.0 # via # -r docs.in # mkdocstrings-python mkdocstrings-python==0.7.1 # via mkdocstrings mypy-extensions==0.4.3 # via black packaging==21.3 # via mkdocs pathspec==0.10.1 # via black platformdirs==2.5.2 # via black pygments==2.15.0 # via mkdocs-material pymdown-extensions==10.0 # via # mkdocs-material # mkdocstrings pyparsing==3.0.9 # via packaging python-dateutil==2.8.2 # via ghp-import pyyaml==6.0 # via # mkdocs # pymdown-extensions # pyyaml-env-tag pyyaml-env-tag==0.1 # via mkdocs rcslice==1.1.0 # via mdx-include six==1.16.0 # via python-dateutil watchdog==2.1.9 # via mkdocs zipp==3.8.1 # via importlib-metadata python-watchfiles-0.21.0/requirements/linting.in000066400000000000000000000000431451223651600217630ustar00rootroot00000000000000black isort[colors] mypy ruff trio python-watchfiles-0.21.0/requirements/linting.txt000066400000000000000000000015771451223651600222110ustar00rootroot00000000000000# # This file is autogenerated by pip-compile with python 3.10 # To update, run: # # pip-compile --output-file=requirements/linting.txt requirements/linting.in # async-generator==1.10 # via trio attrs==22.1.0 # via trio black==22.8.0 # via -r requirements/linting.in click==8.1.3 # via black colorama==0.4.5 # via isort idna==3.3 # via trio isort[colors]==5.10.1 # via -r requirements/linting.in mypy==0.971 # via -r requirements/linting.in mypy-extensions==0.4.3 # via # black # mypy outcome==1.2.0 # via trio pathspec==0.10.1 # via black platformdirs==2.5.2 # via black ruff==0.0.285 # via -r requirements/linting.in sniffio==1.3.0 # via trio sortedcontainers==2.4.0 # via trio tomli==2.0.1 # via # black # mypy trio==0.21.0 # via -r requirements/linting.in typing-extensions==4.3.0 # via mypy python-watchfiles-0.21.0/requirements/pyproject.txt000066400000000000000000000004121451223651600225470ustar00rootroot00000000000000# # This file is autogenerated by pip-compile with python 3.10 # To update, run: # # pip-compile --output-file=requirements/pyproject.txt pyproject.toml # anyio==3.6.1 # via watchfiles (pyproject.toml) idna==3.3 # via anyio sniffio==1.3.0 # via anyio python-watchfiles-0.21.0/requirements/testing.in000066400000000000000000000001061451223651600217740ustar00rootroot00000000000000coverage dirty-equals pytest pytest-mock pytest-pretty pytest-timeout python-watchfiles-0.21.0/requirements/testing.txt000066400000000000000000000015311451223651600222100ustar00rootroot00000000000000# # This file is autogenerated by pip-compile with Python 3.10 # by the following command: # # pip-compile --output-file=requirements/testing.txt requirements/testing.in # commonmark==0.9.1 # via rich coverage==6.4.4 # via -r testing.in dirty-equals==0.5.0 # via -r testing.in iniconfig==1.1.1 # via pytest packaging==21.3 # via pytest pluggy==1.0.0 # via pytest pygments==2.15.0 # via rich pyparsing==3.0.9 # via packaging pytest==7.4.2 # via # -r testing.in # pytest-mock # pytest-pretty # pytest-timeout pytest-mock==3.8.2 # via -r testing.in pytest-pretty==0.0.1 # via -r testing.in pytest-timeout==2.1.0 # via -r testing.in pytz==2022.2.1 # via dirty-equals rich==12.6.0 # via pytest-pretty tomli==2.0.1 # via pytest typing-extensions==4.3.0 # via dirty-equals python-watchfiles-0.21.0/setup.py000066400000000000000000000011161451223651600167600ustar00rootroot00000000000000import sys sys.stderr.write( """ =============================== Unsupported installation method =============================== watchfiles no longer supports installation with `python setup.py install`. Please use `python -m pip install .` instead. """ ) sys.exit(1) # The below code will never execute, however GitHub is particularly # picky about where it finds Python packaging metadata. # See: https://github.com/github/feedback/discussions/6456 # # To be removed once GitHub catches up. setup( name='watchfiles', install_requires=[ 'anyio>=3.0.0,<4', ], ) python-watchfiles-0.21.0/src/000077500000000000000000000000001451223651600160365ustar00rootroot00000000000000python-watchfiles-0.21.0/src/lib.rs000066400000000000000000000335611451223651600171620ustar00rootroot00000000000000extern crate notify; extern crate pyo3; use std::collections::HashSet; use std::io::ErrorKind as IOErrorKind; use std::path::Path; use std::sync::{Arc, Mutex}; use std::thread::sleep; use std::time::{Duration, SystemTime}; use pyo3::create_exception; use pyo3::exceptions::{PyFileNotFoundError, PyOSError, PyPermissionError, PyRuntimeError, PyTypeError}; use pyo3::prelude::*; use notify::event::{Event, EventKind, ModifyKind, RenameMode}; use notify::{ Config as NotifyConfig, ErrorKind as NotifyErrorKind, PollWatcher, RecommendedWatcher, RecursiveMode, Result as NotifyResult, Watcher, }; create_exception!( _rust_notify, WatchfilesRustInternalError, PyRuntimeError, "Internal or filesystem error." ); // these need to match `watchfiles/main.py::Change` const CHANGE_ADDED: u8 = 1; const CHANGE_MODIFIED: u8 = 2; const CHANGE_DELETED: u8 = 3; #[derive(Debug)] enum WatcherEnum { None, Poll(PollWatcher), Recommended(RecommendedWatcher), } #[pyclass] struct RustNotify { changes: Arc>>, error: Arc>>, debug: bool, watcher: WatcherEnum, } fn map_watch_error(error: notify::Error) -> PyErr { let err_string = error.to_string(); match error.kind { NotifyErrorKind::PathNotFound => return PyFileNotFoundError::new_err(err_string), NotifyErrorKind::Generic(ref err) => { // on Windows, we get a Generic with this message when the path does not exist if err.as_str() == "Input watch path is neither a file nor a directory." { return PyFileNotFoundError::new_err(err_string); } } NotifyErrorKind::Io(ref io_error) => match io_error.kind() { IOErrorKind::NotFound => return PyFileNotFoundError::new_err(err_string), IOErrorKind::PermissionDenied => return PyPermissionError::new_err(err_string), _ => (), }, _ => (), }; PyOSError::new_err(format!("{} ({:?})", err_string, error)) } // macro to avoid duplicated code below macro_rules! watcher_paths { ($watcher:ident, $paths:ident, $debug:ident, $recursive:ident, $ignore_permission_denied:ident) => { let mode = if $recursive { RecursiveMode::Recursive } else { RecursiveMode::NonRecursive }; for watch_path in $paths.into_iter() { let result = $watcher.watch(Path::new(&watch_path), mode); match result { Err(err) => { let err = map_watch_error(err); if !$ignore_permission_denied { return Err(err); } } _ => (), } } if $debug { eprintln!("watcher: {:?}", $watcher); } }; } macro_rules! wf_error { ($msg:expr) => { Err(WatchfilesRustInternalError::new_err($msg)) }; ($msg:literal, $( $msg_args:expr ),+ ) => { Err(WatchfilesRustInternalError::new_err(format!($msg, $( $msg_args ),+))) }; } #[pymethods] impl RustNotify { #[new] fn py_new( watch_paths: Vec, debug: bool, force_polling: bool, poll_delay_ms: u64, recursive: bool, ignore_permission_denied: bool, ) -> PyResult { let changes: Arc>> = Arc::new(Mutex::new(HashSet::<(u8, String)>::new())); let error: Arc>> = Arc::new(Mutex::new(None)); let changes_clone = changes.clone(); let error_clone = error.clone(); let event_handler = move |res: NotifyResult| match res { Ok(event) => { if let Some(path_buf) = event.paths.first() { let path = match path_buf.to_str() { Some(s) => s.to_string(), None => { let msg = format!("Unable to decode path {:?} to string", path_buf); *error_clone.lock().unwrap() = Some(msg); return; } }; let change = match event.kind { EventKind::Create(_) => CHANGE_ADDED, EventKind::Modify(ModifyKind::Metadata(_)) | EventKind::Modify(ModifyKind::Data(_)) | EventKind::Modify(ModifyKind::Other) | EventKind::Modify(ModifyKind::Any) => { // these events sometimes happen when creating files and deleting them, hence these checks let changes = changes_clone.lock().unwrap(); if changes.contains(&(CHANGE_DELETED, path.clone())) || changes.contains(&(CHANGE_ADDED, path.clone())) { // file was already deleted or file was added in this batch, ignore this event return; } else { CHANGE_MODIFIED } } EventKind::Modify(ModifyKind::Name(RenameMode::From)) => CHANGE_DELETED, EventKind::Modify(ModifyKind::Name(RenameMode::To)) => CHANGE_ADDED, // RenameMode::Both duplicates RenameMode::From & RenameMode::To EventKind::Modify(ModifyKind::Name(RenameMode::Both)) => return, EventKind::Modify(ModifyKind::Name(_)) => { // On macOS the modify name event is triggered when a file is renamed, // but no information about whether it's the src or dst path is available. // Hence we have to check if the file exists instead. if Path::new(&path).exists() { CHANGE_ADDED } else { CHANGE_DELETED } } EventKind::Remove(_) => CHANGE_DELETED, event_kind => { if debug { eprintln!( "raw-event={:?} event.kind={:?} no change detected", event_kind, event_kind ); } return; } }; if debug { eprintln!("raw-event={:?} change={:?}", event, change); } changes_clone.lock().unwrap().insert((change, path)); } else if debug { eprintln!("raw-event={:?} no paths found", event); } } Err(e) => { *error_clone.lock().unwrap() = Some(format!("error in underlying watcher: {}", e)); } }; macro_rules! create_poll_watcher { ($msg_template:literal) => {{ if watch_paths.iter().any(|p| !Path::new(p).exists()) { return Err(PyFileNotFoundError::new_err("No such file or directory")); } let delay = Duration::from_millis(poll_delay_ms); let config = NotifyConfig::default().with_poll_interval(delay); let mut watcher = match PollWatcher::new(event_handler, config) { Ok(watcher) => watcher, Err(e) => return wf_error!($msg_template, e), }; watcher_paths!(watcher, watch_paths, debug, recursive, ignore_permission_denied); Ok(WatcherEnum::Poll(watcher)) }}; } let watcher: WatcherEnum = match force_polling { true => create_poll_watcher!("Error creating poll watcher: {}"), false => { match RecommendedWatcher::new(event_handler.clone(), NotifyConfig::default()) { Ok(watcher) => { let mut watcher = watcher; watcher_paths!(watcher, watch_paths, debug, recursive, ignore_permission_denied); Ok(WatcherEnum::Recommended(watcher)) } Err(error) => { match &error.kind { NotifyErrorKind::Io(io_error) => { if io_error.raw_os_error() == Some(38) { // see https://github.com/samuelcolvin/watchfiles/issues/167 // we callback to PollWatcher if debug { eprintln!( "IO error using recommend watcher: {:?}, falling back to PollWatcher", io_error ); } create_poll_watcher!("Error creating fallback poll watcher: {}") } else { wf_error!("Error creating recommended watcher: {}", error) } } _ => { wf_error!("Error creating recommended watcher: {}", error) } } } } } }?; Ok(RustNotify { changes, error, debug, watcher, }) } pub fn watch( slf: &PyCell, py: Python, debounce_ms: u64, step_ms: u64, timeout_ms: u64, stop_event: PyObject, ) -> PyResult { if matches!(slf.borrow().watcher, WatcherEnum::None) { return Err(PyRuntimeError::new_err("RustNotify watcher closed")); } let stop_event_is_set: Option<&PyAny> = match stop_event.is_none(py) { true => None, false => { let event: &PyAny = stop_event.extract(py)?; let func: &PyAny = event.getattr("is_set")?.extract()?; if !func.is_callable() { return Err(PyTypeError::new_err("'stop_event.is_set' must be callable")); } Some(func) } }; let mut max_debounce_time: Option = None; let step_time = Duration::from_millis(step_ms); let mut last_size: usize = 0; let max_timeout_time: Option = match timeout_ms { 0 => None, _ => Some(SystemTime::now() + Duration::from_millis(timeout_ms)), }; loop { py.allow_threads(|| sleep(step_time)); match py.check_signals() { Ok(_) => (), Err(_) => { slf.borrow().clear(); return Ok("signal".to_object(py)); } }; if let Some(error) = slf.borrow().error.lock().unwrap().as_ref() { slf.borrow().clear(); return wf_error!(error.clone()); } if let Some(is_set) = stop_event_is_set { if is_set.call0()?.is_true()? { if slf.borrow().debug { eprintln!("stop event set, stopping..."); } slf.borrow().clear(); return Ok("stop".to_object(py)); } } let size = slf.borrow().changes.lock().unwrap().len(); if size > 0 { if size == last_size { break; } last_size = size; let now = SystemTime::now(); if let Some(max_time) = max_debounce_time { if now > max_time { break; } } else { max_debounce_time = Some(now + Duration::from_millis(debounce_ms)); } } else if let Some(max_time) = max_timeout_time { if SystemTime::now() > max_time { slf.borrow().clear(); return Ok("timeout".to_object(py)); } } } let py_changes = slf.borrow().changes.lock().unwrap().to_object(py); slf.borrow().clear(); Ok(py_changes) } /// https://github.com/PyO3/pyo3/issues/1205#issuecomment-1164096251 for advice on `__enter__` pub fn __enter__(slf: Py) -> Py { slf } pub fn close(&mut self) { self.watcher = WatcherEnum::None; } pub fn __exit__(&mut self, _exc_type: PyObject, _exc_value: PyObject, _traceback: PyObject) { self.close(); } pub fn __repr__(&self) -> PyResult { Ok(format!("RustNotify({:#?})", self.watcher)) } } impl RustNotify { fn clear(&self) { self.changes.lock().unwrap().clear(); } } #[pymodule] fn _rust_notify(py: Python, m: &PyModule) -> PyResult<()> { let mut version = env!("CARGO_PKG_VERSION").to_string(); // cargo uses "1.0-alpha1" etc. while python uses "1.0.0a1", this is not full compatibility, // but it's good enough for now // see https://docs.rs/semver/1.0.9/semver/struct.Version.html#method.parse for rust spec // see https://peps.python.org/pep-0440/ for python spec // it seems the dot after "alpha/beta" e.g. "-alpha.1" is not necessary, hence why this works version = version.replace("-alpha", "a").replace("-beta", "b"); m.add("__version__", version)?; m.add( "WatchfilesRustInternalError", py.get_type::(), )?; m.add_class::()?; Ok(()) } python-watchfiles-0.21.0/tests/000077500000000000000000000000001451223651600164115ustar00rootroot00000000000000python-watchfiles-0.21.0/tests/__init__.py000066400000000000000000000000001451223651600205100ustar00rootroot00000000000000python-watchfiles-0.21.0/tests/conftest.py000066400000000000000000000102461451223651600206130ustar00rootroot00000000000000import logging import os import sys from pathlib import Path from threading import Thread from time import sleep, time from typing import TYPE_CHECKING, Any, List, Set, Tuple import pytest @pytest.fixture def tmp_work_path(tmp_path: Path): """ Create a temporary working directory. """ previous_cwd = Path.cwd() os.chdir(tmp_path) yield tmp_path os.chdir(previous_cwd) @pytest.fixture(scope='session') def test_dir(): d = Path(__file__).parent / 'test_files' files = {p: p.read_text() for p in d.glob('**/*.*')} yield d for f in d.glob('**/*.*'): f.unlink() for path, content in files.items(): path.write_text(content) @pytest.fixture(autouse=True) def anyio_backend(): return 'asyncio' def sleep_write(path: Path): sleep(0.1) path.write_text('hello') @pytest.fixture def write_soon(): threads = [] def start(path: Path): thread = Thread(target=sleep_write, args=(path,)) thread.start() threads.append(thread) yield start for t in threads: t.join() ChangesType = List[Set[Tuple[int, str]]] class MockRustNotify: def __init__(self, changes: ChangesType, exit_code: str): self.iter_changes = iter(changes) self.exit_code = exit_code self.watch_count = 0 def watch(self, debounce_ms: int, step_ms: int, timeout_ms: int, cancel_event): try: change = next(self.iter_changes) except StopIteration: return self.exit_code else: self.watch_count += 1 return change def __enter__(self): return self def __exit__(self, *args): self.close() def close(self): pass if TYPE_CHECKING: from typing import Literal, Protocol class MockRustType(Protocol): def __call__(self, changes: ChangesType, *, exit_code: Literal['signal', 'stop', 'timeout'] = 'stop') -> Any: ... @pytest.fixture def mock_rust_notify(mocker): def mock(changes: ChangesType, *, exit_code: str = 'stop'): m = MockRustNotify(changes, exit_code) mocker.patch('watchfiles.main.RustNotify', return_value=m) return m return mock @pytest.fixture(autouse=True) def ensure_logging_framework_not_altered(): """ https://github.com/pytest-dev/pytest/issues/5743 """ wg_logger = logging.getLogger('watchfiles') before_handlers = list(wg_logger.handlers) yield wg_logger.handlers = before_handlers py_code = """ import sys from pathlib import Path def foobar(): Path('sentinel').write_text(' '.join(map(str, sys.argv[1:]))) """ @pytest.fixture def create_test_function(tmp_work_path: Path): original_path = sys.path[:] (tmp_work_path / 'test_function.py').write_text(py_code) sys.path.append(str(tmp_work_path)) yield 'test_function.foobar' sys.path = original_path @pytest.fixture def reset_argv(): original_argv = sys.argv[:] yield sys.argv = original_argv class TimeTaken: def __init__(self, name: str, min_time: int, max_time: int): self.name = name self.min_time = min_time self.max_time = max_time self.start = time() def __enter__(self): return self def __exit__(self, exc_type, *args): diff = (time() - self.start) * 1000 if exc_type is None: if diff > self.max_time: pytest.fail(f'{self.name} code took too long: {diff:0.2f}ms') return elif diff < self.min_time: pytest.fail(f'{self.name} code did not take long enough: {diff:0.2f}ms') return print(f'{self.name} code took {diff:0.2f}ms') @pytest.fixture def time_taken(request): def time_taken(min_time: int, max_time: int): return TimeTaken(request.node.name, min_time, max_time) return time_taken class SetEnv: def __init__(self): self.envars = set() def __call__(self, name, value): self.envars.add(name) os.environ[name] = value def clear(self): for n in self.envars: os.environ.pop(n) @pytest.fixture def env(): setenv = SetEnv() yield setenv setenv.clear() python-watchfiles-0.21.0/tests/test_cli.py000066400000000000000000000260021451223651600205710ustar00rootroot00000000000000import os import sys from pathlib import Path import pytest from dirty_equals import HasAttributes, HasLen, IsInstance from watchfiles import BaseFilter, DefaultFilter, PythonFilter from watchfiles.cli import build_filter, cli pytestmark = pytest.mark.skipif(sys.platform == 'win32', reason='many tests fail on windows') def test_function(mocker, tmp_path): mocker.patch('watchfiles.cli.sys.stdin.fileno') mocker.patch('os.ttyname', return_value='/path/to/tty') mock_run_process = mocker.patch('watchfiles.cli.run_process') cli('os.getcwd', str(tmp_path)) mock_run_process.assert_called_once_with( tmp_path, target='os.getcwd', target_type='function', watch_filter=IsInstance(DefaultFilter, only_direct_instance=True), debug=False, grace_period=0, sigint_timeout=5, sigkill_timeout=1, recursive=True, ignore_permission_denied=False, ) def test_ignore_paths(mocker, tmp_work_path): mocker.patch('watchfiles.cli.sys.stdin.fileno') mocker.patch('os.ttyname', return_value='/path/to/tty') mock_run_process = mocker.patch('watchfiles.cli.run_process') cli( '--ignore-paths', '/foo/bar,/apple/banana', '--filter', 'python', 'os.getcwd', '.', ) mock_run_process.assert_called_once_with( Path(str(tmp_work_path)), target='os.getcwd', target_type='function', watch_filter=( IsInstance(PythonFilter) & HasAttributes(extensions=('.py', '.pyx', '.pyd'), _ignore_paths=('/foo/bar', '/apple/banana')) ), debug=False, grace_period=0, sigint_timeout=5, sigkill_timeout=1, recursive=True, ignore_permission_denied=False, ) class SysError(RuntimeError): pass def test_invalid_import1(mocker, tmp_work_path, capsys): sys_exit = mocker.patch('watchfiles.cli.sys.exit', side_effect=SysError) with pytest.raises(SysError): cli('foo.bar') sys_exit.assert_called_once_with(1) out, err = capsys.readouterr() assert out == '' assert err == "ImportError: No module named 'foo'\n" def test_invalid_import2(mocker, tmp_work_path, capsys): sys_exit = mocker.patch('watchfiles.cli.sys.exit', side_effect=SysError) with pytest.raises(SysError): cli('pprint.foobar') sys_exit.assert_called_once_with(1) out, err = capsys.readouterr() assert out == '' assert err == 'ImportError: Module "pprint" does not define a "foobar" attribute\n' def test_invalid_path(mocker, capsys): sys_exit = mocker.patch('watchfiles.cli.sys.exit', side_effect=SysError) with pytest.raises(SysError): cli('os.getcwd', '/does/not/exist') sys_exit.assert_called_once_with(1) out, err = capsys.readouterr() assert out == '' assert err == 'path "/does/not/exist" does not exist\n' def test_command(mocker, tmp_work_path): mocker.patch('watchfiles.cli.sys.stdin.fileno') mocker.patch('os.ttyname', return_value='/path/to/tty') mock_run_process = mocker.patch('watchfiles.cli.run_process') cli('foo --bar -V 3', '.') mock_run_process.assert_called_once_with( tmp_work_path, target='foo --bar -V 3', target_type='command', watch_filter=IsInstance(DefaultFilter, only_direct_instance=True), debug=False, grace_period=0, sigint_timeout=5, sigkill_timeout=1, recursive=True, ignore_permission_denied=False, ) def test_verbosity(mocker, tmp_path): mocker.patch('watchfiles.cli.sys.stdin.fileno') mocker.patch('os.ttyname', return_value='/path/to/tty') mock_run_process = mocker.patch('watchfiles.cli.run_process') cli('--verbosity', 'debug', 'os.getcwd', str(tmp_path)) mock_run_process.assert_called_once_with( tmp_path, target='os.getcwd', target_type='function', watch_filter=IsInstance(DefaultFilter, only_direct_instance=True), debug=True, grace_period=0, sigint_timeout=5, sigkill_timeout=1, recursive=True, ignore_permission_denied=False, ) def test_verbose(mocker, tmp_path): mocker.patch('watchfiles.cli.sys.stdin.fileno') mocker.patch('os.ttyname', return_value='/path/to/tty') mock_run_process = mocker.patch('watchfiles.cli.run_process') cli('--verbose', 'os.getcwd', str(tmp_path)) mock_run_process.assert_called_once_with( tmp_path, target='os.getcwd', target_type='function', watch_filter=IsInstance(DefaultFilter, only_direct_instance=True), debug=True, grace_period=0, sigint_timeout=5, sigkill_timeout=1, recursive=True, ignore_permission_denied=False, ) def test_non_recursive(mocker, tmp_path): mocker.patch('watchfiles.cli.sys.stdin.fileno') mocker.patch('os.ttyname', return_value='/path/to/tty') mock_run_process = mocker.patch('watchfiles.cli.run_process') cli('--non-recursive', 'os.getcwd', str(tmp_path)) mock_run_process.assert_called_once_with( tmp_path, target='os.getcwd', target_type='function', watch_filter=IsInstance(DefaultFilter, only_direct_instance=True), debug=False, grace_period=0, sigint_timeout=5, sigkill_timeout=1, recursive=False, ignore_permission_denied=False, ) def test_filter_all(mocker, tmp_path, capsys): mocker.patch('watchfiles.cli.sys.stdin.fileno') mocker.patch('os.ttyname', return_value='/path/to/tty') mock_run_process = mocker.patch('watchfiles.cli.run_process') cli('--filter', 'all', '--ignore-paths', 'foo', 'os.getcwd', str(tmp_path)) mock_run_process.assert_called_once_with( tmp_path, target='os.getcwd', target_type='function', watch_filter=None, debug=False, grace_period=0, sigint_timeout=5, sigkill_timeout=1, recursive=True, ignore_permission_denied=False, ) out, err = capsys.readouterr() assert out == '' assert '"--ignore-paths" argument ignored as "all" filter was selected\n' in err def test_filter_default(mocker, tmp_path): mocker.patch('watchfiles.cli.sys.stdin.fileno') mocker.patch('os.ttyname', return_value='/path/to/tty') mock_run_process = mocker.patch('watchfiles.cli.run_process') cli('--filter', 'default', 'os.getcwd', str(tmp_path)) mock_run_process.assert_called_once_with( tmp_path, target='os.getcwd', target_type='function', watch_filter=IsInstance(DefaultFilter, only_direct_instance=True), debug=False, grace_period=0, sigint_timeout=5, sigkill_timeout=1, recursive=True, ignore_permission_denied=False, ) def test_set_type(mocker, tmp_path): mocker.patch('watchfiles.cli.sys.stdin.fileno') mocker.patch('os.ttyname', return_value='/path/to/tty') mock_run_process = mocker.patch('watchfiles.cli.run_process') cli('--target-type', 'command', 'os.getcwd', str(tmp_path)) mock_run_process.assert_called_once_with( tmp_path, target='os.getcwd', target_type='command', watch_filter=IsInstance(DefaultFilter, only_direct_instance=True), debug=False, grace_period=0, sigint_timeout=5, sigkill_timeout=1, recursive=True, ignore_permission_denied=False, ) @pytest.mark.parametrize( 'filter_name,ignore_paths,expected_filter,expected_name', [ ('all', None, None, '(no filter)'), ( 'default', None, IsInstance(DefaultFilter, only_direct_instance=True) & HasAttributes(_ignore_paths=()), 'DefaultFilter', ), ('python', None, IsInstance(PythonFilter, only_direct_instance=True), 'PythonFilter'), ('watchfiles.PythonFilter', None, IsInstance(PythonFilter, only_direct_instance=True), 'PythonFilter'), ('watchfiles.BaseFilter', None, IsInstance(BaseFilter, only_direct_instance=True), 'BaseFilter'), ('os.getcwd', None, os.getcwd, ''), ( 'default', 'foo,bar', IsInstance(DefaultFilter, only_direct_instance=True) & HasAttributes(_ignore_paths=HasLen(2)), 'DefaultFilter', ), ], ) def test_build_filter(filter_name, ignore_paths, expected_filter, expected_name): assert build_filter(filter_name, ignore_paths) == (expected_filter, expected_name) def test_build_filter_warning(caplog): caplog.set_level('INFO', 'watchfiles') watch_filter, name = build_filter('os.getcwd', 'foo') assert watch_filter is os.getcwd assert name == '' assert caplog.text == ( 'watchfiles.cli WARNING: "--ignore-paths" argument ignored as filter is not a subclass of DefaultFilter\n' ) def test_args(mocker, tmp_path, reset_argv, caplog): caplog.set_level('INFO', 'watchfiles') mocker.patch('watchfiles.cli.sys.stdin.fileno') mocker.patch('os.ttyname', return_value='/path/to/tty') mock_run_process = mocker.patch('watchfiles.cli.run_process') cli('--args', '--version ', 'os.getcwd', str(tmp_path)) mock_run_process.assert_called_once_with( tmp_path, target='os.getcwd', target_type='function', watch_filter=IsInstance(DefaultFilter, only_direct_instance=True), debug=False, grace_period=0, sigint_timeout=5, sigkill_timeout=1, recursive=True, ignore_permission_denied=False, ) assert sys.argv == ['os.getcwd', '--version'] assert 'WARNING: --args' not in caplog.text def test_args_command(mocker, tmp_path, caplog): caplog.set_level('INFO', 'watchfiles') mocker.patch('watchfiles.cli.sys.stdin.fileno') mocker.patch('os.ttyname', return_value='/path/to/tty') mock_run_process = mocker.patch('watchfiles.cli.run_process') cli('--args', '--version ', 'foobar.sh', str(tmp_path)) mock_run_process.assert_called_once_with( tmp_path, target='foobar.sh', target_type='command', watch_filter=IsInstance(DefaultFilter, only_direct_instance=True), debug=False, grace_period=0, sigint_timeout=5, sigkill_timeout=1, recursive=True, ignore_permission_denied=False, ) assert 'WARNING: --args is only used when the target is a function\n' in caplog.text def test_ignore_permission_denied(mocker, tmp_path): mocker.patch('watchfiles.cli.sys.stdin.fileno') mocker.patch('os.ttyname', return_value='/path/to/tty') mock_run_process = mocker.patch('watchfiles.cli.run_process') cli('--ignore-permission-denied', 'os.getcwd', str(tmp_path)) mock_run_process.assert_called_once_with( tmp_path, target='os.getcwd', target_type='function', watch_filter=IsInstance(DefaultFilter, only_direct_instance=True), debug=False, grace_period=0, sigint_timeout=5, sigkill_timeout=1, recursive=True, ignore_permission_denied=True, ) python-watchfiles-0.21.0/tests/test_docs.py000066400000000000000000000111641451223651600207550ustar00rootroot00000000000000import importlib.util import re import sys from collections import namedtuple from pathlib import Path from textwrap import dedent from typing import TYPE_CHECKING from unittest.mock import MagicMock import pytest from _pytest.assertion.rewrite import AssertionRewritingHook from watchfiles.cli import cli if TYPE_CHECKING: from conftest import MockRustType pytestmark = pytest.mark.skipif(sys.platform == 'win32', reason='some tests fail on windows') ROOT_DIR = Path(__file__).parent.parent @pytest.fixture def import_execute(request, tmp_work_path: Path): def _import_execute(module_name: str, source: str, rewrite_assertions: bool = False): if rewrite_assertions: loader = AssertionRewritingHook(config=request.config) loader.mark_rewrite(module_name) else: loader = None example_bash_file = tmp_work_path / 'example.sh' example_bash_file.write_text('#!/bin/sh\necho testing') example_bash_file.chmod(0o755) (tmp_work_path / 'first/path').mkdir(parents=True, exist_ok=True) (tmp_work_path / 'second/path').mkdir(parents=True, exist_ok=True) module_path = tmp_work_path / f'{module_name}.py' module_path.write_text(source) spec = importlib.util.spec_from_file_location('__main__', str(module_path), loader=loader) module = importlib.util.module_from_spec(spec) try: spec.loader.exec_module(module) except KeyboardInterrupt: print('KeyboardInterrupt') return _import_execute def extract_code_chunks(path: Path, text: str, offset: int): rel_path = path.relative_to(ROOT_DIR) for m_code in re.finditer(r'^```(.*?)$\n(.*?)^```', text, flags=re.M | re.S): prefix = m_code.group(1).lower() if not prefix.startswith(('py', '{.py')): continue start_line = offset + text[: m_code.start()].count('\n') + 1 code = m_code.group(2) end_line = start_line + code.count('\n') + 1 source = '\n' * start_line + code if 'test="skip"' in prefix: source = '__skip__' yield pytest.param(f'{path.stem}_{start_line}_{end_line}', source, id=f'{rel_path}:{start_line}-{end_line}') def generate_code_chunks(*directories: str): for d in directories: for path in (ROOT_DIR / d).glob('**/*'): if path.suffix == '.py': code = path.read_text() for m_docstring in re.finditer(r'(^\s*)r?"""$(.*?)\1"""', code, flags=re.M | re.S): start_line = code[: m_docstring.start()].count('\n') docstring = dedent(m_docstring.group(2)) yield from extract_code_chunks(path, docstring, start_line) elif path.suffix == '.md': code = path.read_text() yield from extract_code_chunks(path, code, 0) # with pypy we sometimes (???) get a "The loop argument is deprecated since Python 3.8" warning, see # https://github.com/samuelcolvin/watchfiles/runs/7764187741 @pytest.mark.filterwarnings('ignore:The loop argument is deprecated:DeprecationWarning') @pytest.mark.parametrize('module_name,source_code', generate_code_chunks('watchfiles', 'docs')) def test_docs_examples(module_name, source_code, import_execute, mocker, mock_rust_notify: 'MockRustType'): mock_rust_notify([{(1, 'foo.txt'), (2, 'bar.py')}]) mocker.patch('watchfiles.run.spawn_context.Process') mocker.patch('watchfiles.run.os.kill') if source_code == '__skip__': pytest.skip('test="skip" on code snippet') async def dont_sleep(t): pass mocker.patch('asyncio.sleep', new=dont_sleep) # avoid installing aiohttp by mocking it sys.modules['aiohttp'] = type('aiohttp', (), {'web': MagicMock()}) try: import_execute(module_name, source_code, True) except Exception: sys.modules.pop('aiohttp', None) raise @pytest.mark.skipif(sys.version_info[:2] != (3, 10), reason='output varies between versions') def test_cli_help(mocker, capsys): mocker.patch('watchfiles.cli.argparse.ArgumentParser.exit', side_effect=RuntimeError('custom exit')) TerminalSize = namedtuple('TerminalSize', ['columns', 'lines']) mocker.patch('shutil.get_terminal_size', return_value=TerminalSize(80, 24)) with pytest.raises(RuntimeError, match='custom exit'): cli('--help') out, err = capsys.readouterr() assert err == '' cli_help_path = ROOT_DIR / 'docs' / 'cli_help.txt' try: assert out == cli_help_path.read_text(), f'cli help output differs from {cli_help_path}, file updated' except AssertionError: cli_help_path.write_text(out) raise python-watchfiles-0.21.0/tests/test_files/000077500000000000000000000000001451223651600205525ustar00rootroot00000000000000python-watchfiles-0.21.0/tests/test_files/README.md000066400000000000000000000001201451223651600220220ustar00rootroot00000000000000This directory is required for testing due to event delays in FsEvent on macOS. python-watchfiles-0.21.0/tests/test_files/a.txt000066400000000000000000000000021451223651600215230ustar00rootroot00000000000000a python-watchfiles-0.21.0/tests/test_files/a_non_recursive.txt000066400000000000000000000000201451223651600244640ustar00rootroot00000000000000a_non_recursive python-watchfiles-0.21.0/tests/test_files/b.txt000066400000000000000000000000021451223651600215240ustar00rootroot00000000000000b python-watchfiles-0.21.0/tests/test_files/c.txt000066400000000000000000000000021451223651600215250ustar00rootroot00000000000000c python-watchfiles-0.21.0/tests/test_files/c_non_recursive.txt000066400000000000000000000000201451223651600244660ustar00rootroot00000000000000c_non_recursive python-watchfiles-0.21.0/tests/test_files/dir_a/000077500000000000000000000000001451223651600216305ustar00rootroot00000000000000python-watchfiles-0.21.0/tests/test_files/dir_a/a.txt000066400000000000000000000000021451223651600226010ustar00rootroot00000000000000a python-watchfiles-0.21.0/tests/test_files/dir_a/a_non_recursive.txt000066400000000000000000000000201451223651600255420ustar00rootroot00000000000000a_non_recursive python-watchfiles-0.21.0/tests/test_files/dir_a/b.txt000066400000000000000000000000021451223651600226020ustar00rootroot00000000000000b python-watchfiles-0.21.0/tests/test_files/dir_a/c.txt000066400000000000000000000000021451223651600226030ustar00rootroot00000000000000c python-watchfiles-0.21.0/tests/test_files/dir_a/c_non_recursive.txt000066400000000000000000000000201451223651600255440ustar00rootroot00000000000000c_non_recursive python-watchfiles-0.21.0/tests/test_files/dir_a/d.txt000066400000000000000000000000021451223651600226040ustar00rootroot00000000000000d python-watchfiles-0.21.0/tests/test_files/dir_a/e.txt000066400000000000000000000000021451223651600226050ustar00rootroot00000000000000e python-watchfiles-0.21.0/tests/test_files/dir_a/f.txt000066400000000000000000000000021451223651600226060ustar00rootroot00000000000000f python-watchfiles-0.21.0/tests/test_files/dir_b/000077500000000000000000000000001451223651600216315ustar00rootroot00000000000000python-watchfiles-0.21.0/tests/test_files/dir_b/.gitkeep000066400000000000000000000000241451223651600232560ustar00rootroot00000000000000keep this directory python-watchfiles-0.21.0/tests/test_filters.py000066400000000000000000000065371451223651600215050ustar00rootroot00000000000000import re import sys from pathlib import Path from typing import TYPE_CHECKING import pytest from dirty_equals import IsTuple from watchfiles import Change, DefaultFilter, PythonFilter, watch if TYPE_CHECKING: from conftest import MockRustType def test_ignore_file(mock_rust_notify: 'MockRustType'): mock = mock_rust_notify([{(1, 'spam.pyc'), (1, 'spam.swp'), (1, 'foo.txt')}]) assert next(watch('.')) == {(Change.added, 'foo.txt')} assert mock.watch_count == 1 def test_ignore_dir(mock_rust_notify: 'MockRustType'): mock_rust_notify([{(1, '.git'), (1, str(Path('.git') / 'spam')), (1, 'foo.txt')}]) assert next(watch('.')) == {(Change.added, 'foo.txt')} def test_python(mock_rust_notify: 'MockRustType'): mock_rust_notify([{(2, 'spam.txt'), (2, 'spam.md'), (2, 'foo.py')}]) assert next(watch('.', watch_filter=PythonFilter())) == {(Change.modified, 'foo.py')} def test_python_extensions(mock_rust_notify: 'MockRustType'): mock_rust_notify([{(1, 'spam.txt'), (1, 'spam.md'), (1, 'foo.py')}]) f = PythonFilter(extra_extensions=('.md',)) assert next(watch('.', watch_filter=f)) == {(Change.added, 'foo.py'), (Change.added, 'spam.md')} def test_web_filter(mock_rust_notify: 'MockRustType'): # test case from docs class WebFilter(DefaultFilter): allowed_extensions = '.html', '.css', '.js' def __call__(self, change: Change, path: str) -> bool: return super().__call__(change, path) and path.endswith(self.allowed_extensions) mock_rust_notify([{(1, 'foo.txt'), (2, 'bar.html'), (3, 'spam.xlsx'), (1, '.other.js')}]) assert next(watch('.', watch_filter=WebFilter())) == {(Change.modified, 'bar.html'), (Change.added, '.other.js')} def test_simple_function(mock_rust_notify: 'MockRustType'): mock_rust_notify([{(1, 'added.txt'), (2, 'mod.txt'), (3, 'del.txt')}]) def only_added(change: Change, path: str) -> bool: return change == Change.added assert next(watch('.', watch_filter=only_added)) == {(Change.added, 'added.txt')} @pytest.mark.parametrize( 'path,expected', [ ('foo.txt', True), ('foo.swp', False), ('foo.swx', False), ('foo.swx.more', True), (Path('x/y/z/foo.txt'), True), (Path.home() / 'ignore' / 'foo.txt', False), (Path.home() / 'ignore', False), (Path.home() / '.git' / 'foo.txt', False), (Path.home() / 'foo' / 'foo.txt', True), (Path('.git') / 'foo.txt', False), ], ) def test_default_filter(path, expected): f = DefaultFilter(ignore_paths=[Path.home() / 'ignore']) assert f(Change.added, str(path)) == expected @pytest.mark.skipif(sys.platform == 'win32', reason='paths are different on windows') def test_customising_filters(): f = DefaultFilter(ignore_dirs=['apple', 'banana'], ignore_entity_patterns=[r'\.cat$'], ignore_paths=[Path('/a/b')]) assert f.ignore_dirs == ['apple', 'banana'] assert f._ignore_dirs == {'apple', 'banana'} assert f.ignore_entity_patterns == [r'\.cat$'] assert f._ignore_entity_regexes == (re.compile(r'\.cat$'),) assert f.ignore_paths == [Path('/a/b')] assert f._ignore_paths == ('/a/b',) # unchanged assert DefaultFilter.ignore_dirs == IsTuple('__pycache__', length=12) def test_repr(): f = DefaultFilter(ignore_dirs=['apple', 'banana']) assert repr(f).startswith('DefaultFilter(_ignore_dirs={') python-watchfiles-0.21.0/tests/test_force_polling.py000066400000000000000000000047671451223651600226620ustar00rootroot00000000000000from __future__ import annotations as _annotations from typing import TYPE_CHECKING import pytest from watchfiles import watch from watchfiles.main import _default_force_polling if TYPE_CHECKING: from .conftest import SetEnv class MockRustNotify: @staticmethod def watch(*args): return 'stop' def __enter__(self): return self def __exit__(self, *args): pass def test_watch_polling_not_env(mocker): m = mocker.patch('watchfiles.main.RustNotify', return_value=MockRustNotify()) for _ in watch('.'): pass m.assert_called_once_with(['.'], False, False, 300, True, False) def test_watch_polling_env(mocker, env: SetEnv): env('WATCHFILES_FORCE_POLLING', '1') m = mocker.patch('watchfiles.main.RustNotify', return_value=MockRustNotify()) for _ in watch('.'): pass m.assert_called_once_with(['.'], False, True, 300, True, False) @pytest.mark.parametrize( 'env_var,arg,expected', [ (None, True, True), (None, False, False), (None, None, False), ('', True, True), ('', False, False), ('', None, False), ('1', True, True), ('1', False, False), ('1', None, True), ('disable', True, True), ('disable', False, False), ('disable', None, False), ], ) def test_default_force_polling(mocker, env: SetEnv, env_var, arg, expected): uname = type('Uname', (), {'system': 'Linux', 'release': '1'}) mocker.patch('platform.uname', return_value=uname()) if env_var is not None: env('WATCHFILES_FORCE_POLLING', env_var) assert _default_force_polling(arg) == expected @pytest.mark.parametrize( 'env_var,arg,expected,call_count', [ (None, True, True, 0), (None, False, False, 0), (None, None, True, 1), ('', True, True, 0), ('', False, False, 0), ('', None, True, 1), ('1', True, True, 0), ('1', False, False, 0), ('1', None, True, 0), ('disable', True, True, 0), ('disable', False, False, 0), ('disable', None, False, 0), ], ) def test_default_force_polling_wsl(mocker, env: SetEnv, env_var, arg, expected, call_count): uname = type('Uname', (), {'system': 'Linux', 'release': 'Microsoft-Standard'}) m = mocker.patch('platform.uname', return_value=uname()) if env_var is not None: env('WATCHFILES_FORCE_POLLING', env_var) assert _default_force_polling(arg) == expected assert m.call_count == call_count python-watchfiles-0.21.0/tests/test_run_process.py000066400000000000000000000253231451223651600223710ustar00rootroot00000000000000import os import subprocess import sys from multiprocessing.context import SpawnProcess from pathlib import Path from typing import TYPE_CHECKING import pytest from dirty_equals import IsStr from watchfiles import arun_process, run_process from watchfiles.main import Change from watchfiles.run import detect_target_type, import_string, run_function, set_tty, split_cmd, start_process if TYPE_CHECKING: from conftest import MockRustType class FakeProcess(SpawnProcess): def __init__(self, is_alive=True, exitcode=1, pid=123): self._is_alive = is_alive self._exitcode = exitcode self._pid = pid @property def exitcode(self): return self._exitcode @property def pid(self): return self._pid def start(self): pass def is_alive(self): return self._is_alive def join(self, wait): pass def test_alive_terminates(mocker, mock_rust_notify: 'MockRustType', caplog): caplog.set_level('DEBUG', 'watchfiles') mock_spawn_process = mocker.patch('watchfiles.run.spawn_context.Process', return_value=FakeProcess()) mock_popen = mocker.patch('watchfiles.run.subprocess.Popen', return_value=FakePopen()) mock_kill = mocker.patch('watchfiles.run.os.kill') mock_rust_notify([{(1, '/path/to/foobar.py')}]) assert run_process('/x/y/z', target=os.getcwd, debounce=5, grace_period=0.01, step=1) == 1 assert mock_spawn_process.call_count == 2 assert mock_popen.call_count == 0 assert mock_kill.call_count == 2 # kill in loop + final kill assert 'watchfiles.main DEBUG: running "" as function\n' in caplog.text assert 'sleeping for 0.01 seconds before watching for changes' in caplog.text def test_dead_callback(mocker, mock_rust_notify: 'MockRustType'): mock_spawn_process = mocker.patch('watchfiles.run.spawn_context.Process', return_value=FakeProcess(is_alive=False)) mock_kill = mocker.patch('watchfiles.run.os.kill') mock_rust_notify([{(1, '/path/to/foobar.py')}, {(1, '/path/to/foobar.py')}]) c = mocker.MagicMock() assert run_process('/x/y/z', target=object(), callback=c, debounce=5, step=1) == 2 assert mock_spawn_process.call_count == 3 assert mock_kill.call_count == 0 assert c.call_count == 2 c.assert_called_with({(Change.added, '/path/to/foobar.py')}) @pytest.mark.skipif(sys.platform != 'win32', reason='no need to test this except on windows') def test_split_cmd_non_posix(): assert split_cmd('C:\\Users\\default\\AppData\\Local\\Programs\\Python\\Python311\\python.exe -V') == [ 'C:\\Users\\default\\AppData\\Local\\Programs\\Python\\Python311\\python.exe', '-V', ] @pytest.mark.skipif(sys.platform == 'win32', reason='no need to test this on windows') def test_split_cmd_posix(): assert split_cmd('/usr/bin/python3 -v') == ['/usr/bin/python3', '-v'] @pytest.mark.skipif(sys.platform == 'win32', reason='fails on windows') def test_alive_doesnt_terminate(mocker, mock_rust_notify: 'MockRustType'): mock_spawn_process = mocker.patch('watchfiles.run.spawn_context.Process', return_value=FakeProcess(exitcode=None)) mock_kill = mocker.patch('watchfiles.run.os.kill') mock_rust_notify([{(1, '/path/to/foobar.py')}]) assert run_process('/x/y/z', target=object(), debounce=5, step=1) == 1 assert mock_spawn_process.call_count == 2 assert mock_kill.call_count == 4 # 2 kills in loop (graceful and termination) + 2 final kills class FakeProcessTimeout(FakeProcess): def join(self, wait): if wait == 'sigint_timeout': raise subprocess.TimeoutExpired('/x/y/z', wait) @pytest.mark.skipif(sys.platform == 'win32', reason='fails on windows') def test_sigint_timeout(mocker, mock_rust_notify: 'MockRustType', caplog): caplog.set_level('WARNING', 'watchfiles') mock_spawn_process = mocker.patch('watchfiles.run.spawn_context.Process', return_value=FakeProcessTimeout()) mock_kill = mocker.patch('watchfiles.run.os.kill') mock_rust_notify([{(1, '/path/to/foobar.py')}]) assert run_process('/x/y/z', target=object(), debounce=5, step=1, sigint_timeout='sigint_timeout') == 1 assert mock_spawn_process.call_count == 2 assert mock_kill.call_count == 2 assert "SIGINT timed out after 'sigint_timeout' seconds" in caplog.text def test_start_process(mocker): mock_process = mocker.patch('watchfiles.run.spawn_context.Process') v = object() start_process(v, 'function', (1, 2, 3), {}) assert mock_process.call_count == 1 mock_process.assert_called_with(target=v, args=(1, 2, 3), kwargs={}) assert os.getenv('WATCHFILES_CHANGES') == '[]' def test_start_process_env(mocker): mock_process = mocker.patch('watchfiles.run.spawn_context.Process') v = object() changes = [(Change.added, 'a.py'), (Change.modified, 'b.py'), (Change.deleted, 'c.py')] # use a list to keep order start_process(v, 'function', (1, 2, 3), {}, changes) assert mock_process.call_count == 1 mock_process.assert_called_with(target=v, args=(1, 2, 3), kwargs={}) assert os.getenv('WATCHFILES_CHANGES') == '[["added", "a.py"], ["modified", "b.py"], ["deleted", "c.py"]]' def test_function_string_not_win(mocker, mock_rust_notify: 'MockRustType', caplog): caplog.set_level('DEBUG', 'watchfiles') mock_spawn_process = mocker.patch('watchfiles.run.spawn_context.Process', return_value=FakeProcess()) mocker.patch('watchfiles.run.os.kill') mock_rust_notify([{(1, '/path/to/foobar.py')}]) assert run_process('/x/y/z', target='os.getcwd', debounce=5, step=1) == 1 assert mock_spawn_process.call_count == 2 # get_tty_path returns None on windows tty_path = None if sys.platform == 'win32' else IsStr(regex='/dev/.+') mock_spawn_process.assert_called_with(target=run_function, args=('os.getcwd', tty_path, (), {}), kwargs={}) assert 'watchfiles.main DEBUG: running "os.getcwd" as function\n' in caplog.text def test_function_list(mocker, mock_rust_notify: 'MockRustType'): mock_spawn_process = mocker.patch('watchfiles.run.spawn_context.Process', return_value=FakeProcess()) mock_kill = mocker.patch('watchfiles.run.os.kill') mock_rust_notify([{(1, '/path/to/foobar.py')}]) assert run_process('/x/y/z', target=['os.getcwd'], debounce=5, step=1) == 1 assert mock_spawn_process.call_count == 2 assert mock_kill.call_count == 2 # kill in loop + final kill async def test_async_alive_terminates(mocker, mock_rust_notify: 'MockRustType'): mock_spawn_process = mocker.patch('watchfiles.run.spawn_context.Process', return_value=FakeProcess()) mock_kill = mocker.patch('watchfiles.run.os.kill') mock_rust_notify([{(1, '/path/to/foobar.py')}]) callback_calls = [] async def c(changes): callback_calls.append(changes) assert await arun_process('/x/y/async', target=object(), callback=c, debounce=5, step=1) == 1 assert mock_spawn_process.call_count == 2 assert mock_kill.call_count == 2 # kill in loop + final kill assert callback_calls == [{(Change.added, '/path/to/foobar.py')}] async def test_async_sync_callback(mocker, mock_rust_notify: 'MockRustType'): mock_spawn_process = mocker.patch('watchfiles.run.spawn_context.Process', return_value=FakeProcess()) mock_kill = mocker.patch('watchfiles.run.os.kill') mock_rust_notify([{(1, '/path/to/foo.py')}, {(2, '/path/to/bar.py')}]) callback_calls = [] v = await arun_process( '/x/y/async', target='os.getcwd', target_type='function', callback=callback_calls.append, grace_period=0.01, debounce=5, step=1, ) assert v == 2 assert mock_spawn_process.call_count == 3 assert mock_kill.call_count == 3 assert callback_calls == [{(Change.added, '/path/to/foo.py')}, {(Change.modified, '/path/to/bar.py')}] def test_run_function(tmp_work_path: Path, create_test_function): assert not (tmp_work_path / 'sentinel').exists() run_function(create_test_function, None, (), {}) assert (tmp_work_path / 'sentinel').exists() def test_run_function_tty(tmp_work_path: Path, create_test_function): # could this cause problems by changing sys.stdin? assert not (tmp_work_path / 'sentinel').exists() run_function(create_test_function, '/dev/tty', (), {}) assert (tmp_work_path / 'sentinel').exists() def test_set_tty_error(): with set_tty('/foo/bar'): pass class FakePopen: def __init__(self, is_alive=True, returncode=1, pid=123): self._is_alive = is_alive self.returncode = returncode self.pid = pid def poll(self): return None if self._is_alive else self.returncode def wait(self, wait): pass def test_command(mocker, mock_rust_notify: 'MockRustType', caplog): caplog.set_level('DEBUG', 'watchfiles') mock_spawn_process = mocker.patch('watchfiles.run.spawn_context.Process', return_value=FakeProcess()) mock_popen = mocker.patch('watchfiles.run.subprocess.Popen', return_value=FakePopen()) mock_kill = mocker.patch('watchfiles.run.os.kill') mock_rust_notify([{(1, '/path/to/foobar.py')}]) assert run_process('/x/y/z', target='echo foobar', debounce=5, step=1) == 1 assert mock_spawn_process.call_count == 0 assert mock_popen.call_count == 2 mock_popen.assert_called_with(['echo', 'foobar']) assert mock_kill.call_count == 2 # kill in loop + final kill assert 'watchfiles.main DEBUG: running "echo foobar" as command\n' in caplog.text def test_command_with_args(mocker, mock_rust_notify: 'MockRustType', caplog): caplog.set_level('INFO', 'watchfiles') mock_spawn_process = mocker.patch('watchfiles.run.spawn_context.Process', return_value=FakeProcess()) mock_popen = mocker.patch('watchfiles.run.subprocess.Popen', return_value=FakePopen()) mock_kill = mocker.patch('watchfiles.run.os.kill') mock_rust_notify([{(1, '/path/to/foobar.py')}]) assert run_process('/x/y/z', target='echo foobar', args=(1, 2), target_type='command', debounce=5, step=1) == 1 assert mock_spawn_process.call_count == 0 assert mock_popen.call_count == 2 mock_popen.assert_called_with(['echo', 'foobar']) assert mock_kill.call_count == 2 # kill in loop + final kill assert 'watchfiles.main WARNING: ignoring args and kwargs for "command" target\n' in caplog.text def test_import_string(): assert import_string('os.getcwd') == os.getcwd with pytest.raises(ImportError, match='"os" doesn\'t look like a module path'): import_string('os') @pytest.mark.parametrize( 'target, expected', [ ('os.getcwd', 'function'), (os.getcwd, 'function'), ('foobar.py', 'command'), ('foobar.sh', 'command'), ('foobar.pyt', 'function'), ('foo bar', 'command'), ], ) def test_detect_target_type(target, expected): assert detect_target_type(target) == expected python-watchfiles-0.21.0/tests/test_rust_notify.py000066400000000000000000000241131451223651600224100ustar00rootroot00000000000000import os import sys from pathlib import Path from typing import TYPE_CHECKING import pytest from watchfiles._rust_notify import RustNotify from watchfiles.main import _default_ignore_permission_denied if TYPE_CHECKING: from .conftest import SetEnv skip_unless_linux = pytest.mark.skipif('linux' not in sys.platform, reason='avoid differences on other systems') skip_windows = pytest.mark.skipif(sys.platform == 'win32', reason='fails on Windows') def test_add(test_dir: Path): watcher = RustNotify([str(test_dir)], True, False, 0, True, False) (test_dir / 'new_file.txt').write_text('foobar') assert watcher.watch(200, 50, 500, None) == {(1, str(test_dir / 'new_file.txt'))} def test_add_non_recursive(test_dir: Path): watcher = RustNotify([str(test_dir)], True, False, 0, False, False) (test_dir / 'new_file_non_recursive.txt').write_text('foobar') (test_dir / 'dir_a' / 'new_file_non_recursive.txt').write_text('foobar') assert watcher.watch(200, 50, 500, None) == {(1, str(test_dir / 'new_file_non_recursive.txt'))} def test_close(test_dir: Path): watcher = RustNotify([str(test_dir)], True, False, 0, True, False) assert repr(watcher).startswith('RustNotify(Recommended(\n') watcher.close() assert repr(watcher) == 'RustNotify(None)' with pytest.raises(RuntimeError, match='RustNotify watcher closed'): watcher.watch(200, 50, 500, None) def test_modify_write(test_dir: Path): watcher = RustNotify([str(test_dir)], True, False, 0, True, False) (test_dir / 'a.txt').write_text('this is new') assert watcher.watch(200, 50, 500, None) == {(2, str(test_dir / 'a.txt'))} def test_modify_write_non_recursive(test_dir: Path): watcher = RustNotify([str(test_dir)], True, False, 0, False, False) (test_dir / 'a_non_recursive.txt').write_text('this is new') (test_dir / 'dir_a' / 'a_non_recursive.txt').write_text('this is new') assert watcher.watch(200, 50, 500, None) == { (2, str(test_dir / 'a_non_recursive.txt')), } @skip_windows def test_modify_chmod(test_dir: Path): watcher = RustNotify([str(test_dir)], True, False, 0, True, False) (test_dir / 'b.txt').chmod(0o444) assert watcher.watch(200, 50, 500, None) == {(2, str(test_dir / 'b.txt'))} def test_delete(test_dir: Path): watcher = RustNotify([str(test_dir)], False, False, 0, True, False) (test_dir / 'c.txt').unlink() assert watcher.watch(200, 50, 500, None) == { (3, str(test_dir / 'c.txt')), } def test_delete_non_recursive(test_dir: Path): watcher = RustNotify([str(test_dir)], False, False, 0, False, False) (test_dir / 'c_non_recursive.txt').unlink() (test_dir / 'dir_a' / 'c_non_recursive.txt').unlink() assert watcher.watch(200, 50, 500, None) == { (3, str(test_dir / 'c_non_recursive.txt')), } def test_move_in(test_dir: Path): # can't use tmp_path as it causes problems on Windows (different drive), and macOS (delayed events) src = test_dir / 'dir_a' assert src.is_dir() dst = test_dir / 'dir_b' assert dst.is_dir() move_files = 'a.txt', 'b.txt' watcher = RustNotify([str(dst)], False, False, 0, True, False) for f in move_files: (src / f).rename(dst / f) assert watcher.watch(200, 50, 500, None) == { (1, str(dst / 'a.txt')), (1, str(dst / 'b.txt')), } def test_move_out(test_dir: Path): # can't use tmp_path as it causes problems on Windows (different drive), and macOS (delayed events) src = test_dir / 'dir_a' dst = test_dir / 'dir_b' move_files = 'c.txt', 'd.txt' watcher = RustNotify([str(src)], False, False, 0, True, False) for f in move_files: (src / f).rename(dst / f) assert watcher.watch(200, 50, 500, None) == { (3, str(src / 'c.txt')), (3, str(src / 'd.txt')), } def test_move_internal(test_dir: Path): # can't use tmp_path as it causes problems on Windows (different drive), and macOS (delayed events) src = test_dir / 'dir_a' dst = test_dir / 'dir_b' move_files = 'e.txt', 'f.txt' watcher = RustNotify([str(test_dir)], False, False, 0, True, False) for f in move_files: (src / f).rename(dst / f) expected_changes = { (3, str(src / 'e.txt')), (3, str(src / 'f.txt')), (1, str(dst / 'e.txt')), (1, str(dst / 'f.txt')), } if sys.platform == 'win32': # Windows adds a "modified" event for the dst directory expected_changes.add((2, str(dst))) assert watcher.watch(200, 50, 500, None) == expected_changes def test_does_not_exist(tmp_path: Path): p = tmp_path / 'missing' with pytest.raises(FileNotFoundError): RustNotify([str(p)], False, False, 0, True, False) @skip_unless_linux def test_does_not_exist_message(tmp_path: Path): p = tmp_path / 'missing' with pytest.raises(FileNotFoundError, match='No such file or directory'): RustNotify([str(p)], False, False, 0, True, False) def test_does_not_exist_polling(tmp_path: Path): p = tmp_path / 'missing' with pytest.raises(FileNotFoundError, match='No such file or directory'): RustNotify([str(p)], False, True, 0, True, False) def test_rename(test_dir: Path): watcher = RustNotify([str(test_dir)], False, False, 0, True, False) f = test_dir / 'a.txt' f.rename(f.with_suffix('.new')) assert watcher.watch(200, 50, 500, None) == { (3, str(f)), (1, str(test_dir / 'a.new')), } def test_watch_multiple(tmp_path: Path): foo = tmp_path / 'foo' foo.mkdir() bar = tmp_path / 'bar' bar.mkdir() watcher = RustNotify([str(foo), str(bar)], False, False, 0, True, False) (tmp_path / 'not_included.txt').write_text('foobar') (foo / 'foo.txt').write_text('foobar') (bar / 'foo.txt').write_text('foobar') changes = watcher.watch(200, 50, 500, None) # can compare directly since on macos creating the foo and bar directories is included in changes assert (1, str(foo / 'foo.txt')) in changes assert (1, str(bar / 'foo.txt')) in changes assert not any('not_included.txt' in p for c, p in changes) def test_wrong_type_event(test_dir: Path, time_taken): watcher = RustNotify([str(test_dir)], False, False, 0, True, False) with pytest.raises(AttributeError, match="'object' object has no attribute 'is_set'"): watcher.watch(100, 1, 500, object()) def test_wrong_type_event_is_set(test_dir: Path, time_taken): watcher = RustNotify([str(test_dir)], False, False, 0, True, False) event = type('BadEvent', (), {'is_set': 123})() with pytest.raises(TypeError, match="'stop_event.is_set' must be callable"): watcher.watch(100, 1, 500, event) @skip_unless_linux def test_return_timeout(test_dir: Path, time_taken): watcher = RustNotify([str(test_dir)], False, False, 0, True, False) with time_taken(40, 70): assert watcher.watch(20, 1, 50, None) == 'timeout' class AbstractEvent: def __init__(self, is_set: bool): self._is_set = is_set def is_set(self) -> bool: return self._is_set @skip_unless_linux def test_return_event_set(test_dir: Path, time_taken): watcher = RustNotify([str(test_dir)], False, False, 0, True, False) with time_taken(0, 20): assert watcher.watch(100, 1, 500, AbstractEvent(True)) == 'stop' @skip_unless_linux def test_return_event_unset(test_dir: Path, time_taken): watcher = RustNotify([str(test_dir)], False, False, 0, True, False) with time_taken(40, 70): assert watcher.watch(20, 1, 50, AbstractEvent(False)) == 'timeout' @skip_unless_linux def test_return_debounce_no_timeout(test_dir: Path, time_taken): # would return sooner if the timeout logic wasn't in an else clause watcher = RustNotify([str(test_dir)], True, False, 0, True, False) (test_dir / 'debounce.txt').write_text('foobar') with time_taken(50, 130): assert watcher.watch(100, 50, 20, None) == {(1, str(test_dir / 'debounce.txt'))} @skip_unless_linux def test_rename_multiple_inside(tmp_path: Path): d1 = tmp_path / 'd1' d1.mkdir() f1 = d1 / '1.txt' f1.write_text('1') f2 = d1 / '2.txt' f2.write_text('2') f3 = d1 / '3.txt' f3.write_text('3') d2 = tmp_path / 'd2' d2.mkdir() watcher_all = RustNotify([str(tmp_path)], False, False, 0, True, False) f1.rename(d2 / '1.txt') f2.rename(d2 / '2.txt') f3.rename(d2 / '3.txt') assert watcher_all.watch(200, 50, 500, None) == { (3, str(f1)), (3, str(f2)), (3, str(f3)), (1, str(d2 / '1.txt')), (1, str(d2 / '2.txt')), (1, str(d2 / '3.txt')), } @skip_windows def test_polling(test_dir: Path): watcher = RustNotify([str(test_dir)], True, True, 100, True, False) (test_dir / 'test_polling.txt').write_text('foobar') changes = watcher.watch(200, 50, 500, None) assert (1, str(test_dir / 'test_polling.txt')) in changes # sometimes has an event modify too def test_not_polling_repr(test_dir: Path): watcher = RustNotify([str(test_dir)], True, False, 123, True, False) r = repr(watcher) assert r.startswith('RustNotify(Recommended(\n') def test_polling_repr(test_dir: Path): watcher = RustNotify([str(test_dir)], True, True, 123, True, False) r = repr(watcher) assert r.startswith('RustNotify(Poll(\n PollWatcher {\n') assert 'delay: 123ms' in r @skip_unless_linux def test_ignore_permission_denied(): path = os.getenv('WATCHFILES_TEST_PERMISSION_DENIED_PATH') or '/' RustNotify([path], False, False, 0, True, True) with pytest.raises(PermissionError): RustNotify([path], False, False, 0, True, False) @pytest.mark.parametrize( 'env_var,arg,expected', [ (None, True, True), (None, False, False), (None, None, False), ('', True, True), ('', False, False), ('', None, False), ('1', True, True), ('1', False, False), ('1', None, True), ], ) def test_default_ignore_permission_denied(env: 'SetEnv', env_var, arg, expected): if env_var is not None: env('WATCHFILES_IGNORE_PERMISSION_DENIED', env_var) assert _default_ignore_permission_denied(arg) == expected python-watchfiles-0.21.0/tests/test_watch.py000066400000000000000000000157421451223651600211410ustar00rootroot00000000000000import sys import threading from contextlib import contextmanager from pathlib import Path from time import sleep from typing import TYPE_CHECKING import anyio import pytest from watchfiles import Change, awatch, watch from watchfiles.main import _calc_async_timeout if TYPE_CHECKING: from conftest import MockRustType def test_watch(tmp_path: Path, write_soon): sleep(0.05) write_soon(tmp_path / 'foo.txt') changes = None for changes in watch(tmp_path, debounce=50, step=10, watch_filter=None): break assert changes == {(Change.added, str((tmp_path / 'foo.txt')))} def test_wait_stop_event(tmp_path: Path, write_soon): sleep(0.05) write_soon(tmp_path / 'foo.txt') stop_event = threading.Event() for changes in watch(tmp_path, debounce=50, step=10, watch_filter=None, stop_event=stop_event): assert changes == {(Change.added, str((tmp_path / 'foo.txt')))} stop_event.set() async def test_awatch(tmp_path: Path, write_soon): sleep(0.05) write_soon(tmp_path / 'foo.txt') async for changes in awatch(tmp_path, debounce=50, step=10, watch_filter=None): assert changes == {(Change.added, str((tmp_path / 'foo.txt')))} break @pytest.mark.filterwarnings('ignore::DeprecationWarning') async def test_await_stop_event(tmp_path: Path, write_soon): sleep(0.05) write_soon(tmp_path / 'foo.txt') stop_event = anyio.Event() async for changes in awatch(tmp_path, debounce=50, step=10, watch_filter=None, stop_event=stop_event): assert changes == {(Change.added, str((tmp_path / 'foo.txt')))} stop_event.set() def test_watch_raise_interrupt(mock_rust_notify: 'MockRustType'): mock_rust_notify([{(1, 'foo.txt')}], exit_code='signal') w = watch('.', raise_interrupt=True) assert next(w) == {(Change.added, 'foo.txt')} with pytest.raises(KeyboardInterrupt): next(w) def test_watch_dont_raise_interrupt(mock_rust_notify: 'MockRustType', caplog): caplog.set_level('WARNING', 'watchfiles') mock_rust_notify([{(1, 'foo.txt')}], exit_code='signal') w = watch('.', raise_interrupt=False) assert next(w) == {(Change.added, 'foo.txt')} with pytest.raises(StopIteration): next(w) assert caplog.text == 'watchfiles.main WARNING: KeyboardInterrupt caught, stopping watch\n' @contextmanager def mock_open_signal_receiver(signal): async def signals(): yield signal yield signals() async def test_awatch_unexpected_signal(mock_rust_notify: 'MockRustType'): mock_rust_notify([{(1, 'foo.txt')}], exit_code='signal') count = 0 with pytest.raises(RuntimeError, match='watch thread unexpectedly received a signal'): async for _ in awatch('.'): count += 1 assert count == 1 async def test_awatch_interrupt_warning(mock_rust_notify: 'MockRustType', caplog): mock_rust_notify([{(1, 'foo.txt')}]) count = 0 with pytest.warns(DeprecationWarning, match='raise_interrupt is deprecated, KeyboardInterrupt will cause this'): async for _ in awatch('.', raise_interrupt=False): count += 1 assert count == 1 def test_watch_no_yield(mock_rust_notify: 'MockRustType', caplog): mock = mock_rust_notify([{(1, 'spam.pyc')}, {(1, 'spam.py'), (2, 'ham.txt')}]) caplog.set_level('INFO', 'watchfiles') assert next(watch('.')) == {(Change.added, 'spam.py'), (Change.modified, 'ham.txt')} assert mock.watch_count == 2 assert caplog.text == 'watchfiles.main INFO: 2 changes detected\n' async def test_awatch_no_yield(mock_rust_notify: 'MockRustType', caplog): mock = mock_rust_notify([{(1, 'spam.pyc')}, {(1, 'spam.py')}]) caplog.set_level('DEBUG', 'watchfiles') changes = None async for changes in awatch('.'): pass assert changes == {(Change.added, 'spam.py')} assert mock.watch_count == 2 assert caplog.text == ( "watchfiles.main DEBUG: all changes filtered out, raw_changes={(1, 'spam.pyc')}\n" "watchfiles.main DEBUG: 1 change detected: {(, 'spam.py')}\n" ) def test_watch_timeout(mock_rust_notify: 'MockRustType', caplog): mock = mock_rust_notify(['timeout', {(1, 'spam.py')}]) caplog.set_level('DEBUG', 'watchfiles') change_list = [] for changes in watch('.'): change_list.append(changes) assert change_list == [{(Change.added, 'spam.py')}] assert mock.watch_count == 2 assert caplog.text == ( "watchfiles.main DEBUG: rust notify timeout, continuing\n" # noqa: Q000 "watchfiles.main DEBUG: 1 change detected: {(, 'spam.py')}\n" ) def test_watch_yield_on_timeout(mock_rust_notify: 'MockRustType'): mock = mock_rust_notify(['timeout', {(1, 'spam.py')}]) change_list = [] for changes in watch('.', yield_on_timeout=True): change_list.append(changes) assert change_list == [set(), {(Change.added, 'spam.py')}] assert mock.watch_count == 2 async def test_awatch_timeout(mock_rust_notify: 'MockRustType', caplog): mock = mock_rust_notify(['timeout', {(1, 'spam.py')}]) caplog.set_level('DEBUG', 'watchfiles') change_list = [] async for changes in awatch('.'): change_list.append(changes) assert change_list == [{(Change.added, 'spam.py')}] assert mock.watch_count == 2 assert caplog.text == ( "watchfiles.main DEBUG: rust notify timeout, continuing\n" # noqa: Q000 "watchfiles.main DEBUG: 1 change detected: {(, 'spam.py')}\n" ) async def test_awatch_yield_on_timeout(mock_rust_notify: 'MockRustType'): mock = mock_rust_notify(['timeout', {(1, 'spam.py')}]) change_list = [] async for changes in awatch('.', yield_on_timeout=True): change_list.append(changes) assert change_list == [set(), {(Change.added, 'spam.py')}] assert mock.watch_count == 2 @pytest.mark.skipif(sys.platform == 'win32', reason='different on windows') def test_calc_async_timeout_posix(): assert _calc_async_timeout(123) == 123 assert _calc_async_timeout(None) == 5_000 @pytest.mark.skipif(sys.platform != 'win32', reason='different on windows') def test_calc_async_timeout_win(): assert _calc_async_timeout(123) == 123 assert _calc_async_timeout(None) == 1_000 class MockRustNotifyRaise: def __init__(self): self.i = 0 def watch(self, *args): if self.i == 1: raise KeyboardInterrupt('test error') self.i += 1 return {(Change.added, 'spam.py')} def __enter__(self): return self def __exit__(self, *args): self.close() def close(self): pass async def test_awatch_interrupt_raise(mocker): mocker.patch('watchfiles.main.RustNotify', return_value=MockRustNotifyRaise()) count = 0 stop_event = threading.Event() with pytest.raises(KeyboardInterrupt, match='test error'): async for _ in awatch('.', stop_event=stop_event): count += 1 # event is set because it's set while handling the KeyboardInterrupt assert stop_event.is_set() assert count == 1 python-watchfiles-0.21.0/watchfiles/000077500000000000000000000000001451223651600174005ustar00rootroot00000000000000python-watchfiles-0.21.0/watchfiles/__init__.py000066400000000000000000000005541451223651600215150ustar00rootroot00000000000000from .filters import BaseFilter, DefaultFilter, PythonFilter from .main import Change, awatch, watch from .run import arun_process, run_process from .version import VERSION __version__ = VERSION __all__ = ( 'watch', 'awatch', 'run_process', 'arun_process', 'Change', 'BaseFilter', 'DefaultFilter', 'PythonFilter', 'VERSION', ) python-watchfiles-0.21.0/watchfiles/__main__.py000066400000000000000000000000731451223651600214720ustar00rootroot00000000000000from .cli import cli if __name__ == '__main__': cli() python-watchfiles-0.21.0/watchfiles/_rust_notify.pyi000066400000000000000000000112771451223651600226570ustar00rootroot00000000000000from typing import Any, List, Literal, Optional, Protocol, Set, Tuple, Union __all__ = 'RustNotify', 'WatchfilesRustInternalError' __version__: str """The package version as defined in `Cargo.toml`, modified to match python's versioning semantics.""" class AbstractEvent(Protocol): def is_set(self) -> bool: ... class RustNotify: """ Interface to the Rust [notify](https://crates.io/crates/notify) crate which does the heavy lifting of watching for file changes and grouping them into events. """ def __init__( self, watch_paths: List[str], debug: bool, force_polling: bool, poll_delay_ms: int, recursive: bool, ignore_permission_denied: bool, ) -> None: """ Create a new `RustNotify` instance and start a thread to watch for changes. `FileNotFoundError` is raised if any of the paths do not exist. Args: watch_paths: file system paths to watch for changes, can be directories or files debug: if true, print details about all events to stderr force_polling: if true, always use polling instead of file system notifications poll_delay_ms: delay between polling for changes, only used if `force_polling=True` recursive: if `True`, watch for changes in sub-directories recursively, otherwise watch only for changes in the top-level directory, default is `True`. ignore_permission_denied: if `True`, permission denied errors are ignored while watching changes. """ def watch( self, debounce_ms: int, step_ms: int, timeout_ms: int, stop_event: Optional[AbstractEvent], ) -> Union[Set[Tuple[int, str]], Literal['signal', 'stop', 'timeout']]: """ Watch for changes. This method will wait `timeout_ms` milliseconds for changes, but once a change is detected, it will group changes and return in no more than `debounce_ms` milliseconds. The GIL is released during a `step_ms` sleep on each iteration to avoid blocking python. Args: debounce_ms: maximum time in milliseconds to group changes over before returning. step_ms: time to wait for new changes in milliseconds, if no changes are detected in this time, and at least one change has been detected, the changes are yielded. timeout_ms: maximum time in milliseconds to wait for changes before returning, `0` means wait indefinitely, `debounce_ms` takes precedence over `timeout_ms` once a change is detected. stop_event: event to check on every iteration to see if this function should return early. The event should be an object which has an `is_set()` method which returns a boolean. Returns: See below. Return values have the following meanings: * Change details as a `set` of `(event_type, path)` tuples, the event types are ints which match [`Change`][watchfiles.Change], `path` is a string representing the path of the file that changed * `'signal'` string, if a signal was received * `'stop'` string, if the `stop_event` was set * `'timeout'` string, if `timeout_ms` was exceeded """ def __enter__(self) -> 'RustNotify': """ Does nothing, but allows `RustNotify` to be used as a context manager. !!! note The watching thead is created when an instance is initiated, not on `__enter__`. """ def __exit__(self, *args: Any) -> None: """ Calls [`close`][watchfiles._rust_notify.RustNotify.close]. """ def close(self) -> None: """ Stops the watching thread. After `close` is called, the `RustNotify` instance can no longer be used, calls to [`watch`][watchfiles._rust_notify.RustNotify.watch] will raise a `RuntimeError`. !!! note `close` is not required, just deleting the `RustNotify` instance will kill the thread implicitly. As per [#163](https://github.com/samuelcolvin/watchfiles/issues/163) `close()` is only required because in the event of an error, the traceback in `sys.exc_info` keeps a reference to `watchfiles.watch`'s frame, so you can't rely on the `RustNotify` object being deleted, and thereby stopping the watching thread. """ class WatchfilesRustInternalError(RuntimeError): """ Raised when RustNotify encounters an unknown error. If you get this a lot, please check [github](https://github.com/samuelcolvin/watchfiles/issues) issues and create a new issue if your problem is not discussed. """ python-watchfiles-0.21.0/watchfiles/cli.py000066400000000000000000000170331451223651600205250ustar00rootroot00000000000000import argparse import logging import os import shlex import sys from pathlib import Path from textwrap import dedent from typing import Any, Callable, List, Optional, Tuple, Union, cast from . import Change from .filters import BaseFilter, DefaultFilter, PythonFilter from .run import detect_target_type, import_string, run_process from .version import VERSION logger = logging.getLogger('watchfiles.cli') def resolve_path(path_str: str) -> Path: path = Path(path_str) if not path.exists(): raise FileNotFoundError(path) else: return path.resolve() def cli(*args_: str) -> None: """ Watch one or more directories and execute either a shell command or a python function on file changes. Example of watching the current directory and calling a python function: watchfiles foobar.main Example of watching python files in two local directories and calling a shell command: watchfiles --filter python 'pytest --lf' src tests See https://watchfiles.helpmanual.io/cli/ for more information. """ args = args_ or sys.argv[1:] parser = argparse.ArgumentParser( prog='watchfiles', description=dedent((cli.__doc__ or '').strip('\n')), formatter_class=argparse.RawTextHelpFormatter, ) parser.add_argument('target', help='Command or dotted function path to run') parser.add_argument( 'paths', nargs='*', default='.', help='Filesystem paths to watch, defaults to current directory' ) parser.add_argument( '--ignore-paths', nargs='?', type=str, help=( 'Specify directories to ignore, ' 'to ignore multiple paths use a comma as separator, e.g. "env" or "env,node_modules"' ), ) parser.add_argument( '--target-type', nargs='?', type=str, default='auto', choices=['command', 'function', 'auto'], help=( 'Whether the target should be intercepted as a shell command or a python function, ' 'defaults to "auto" which infers the target type from the target string' ), ) parser.add_argument( '--filter', nargs='?', type=str, default='default', help=( 'Which files to watch, defaults to "default" which uses the "DefaultFilter", ' '"python" uses the "PythonFilter", "all" uses no filter, ' 'any other value is interpreted as a python function/class path which is imported' ), ) parser.add_argument( '--args', nargs='?', type=str, help='Arguments to set on sys.argv before calling target function, used only if the target is a function', ) parser.add_argument('--verbose', action='store_true', help='Set log level to "debug", wins over `--verbosity`') parser.add_argument( '--non-recursive', action='store_true', help='Do not watch for changes in sub-directories recursively' ) parser.add_argument( '--verbosity', nargs='?', type=str, default='info', choices=['warning', 'info', 'debug'], help='Log level, defaults to "info"', ) parser.add_argument( '--sigint-timeout', nargs='?', type=int, default=5, help='How long to wait for the sigint timeout before sending sigkill.', ) parser.add_argument( '--grace-period', nargs='?', type=float, default=0, help='Number of seconds after the process is started before watching for changes.', ) parser.add_argument( '--sigkill-timeout', nargs='?', type=int, default=1, help='How long to wait for the sigkill timeout before issuing a timeout exception.', ) parser.add_argument( '--ignore-permission-denied', action='store_true', help='Ignore permission denied errors while watching files and directories.', ) parser.add_argument('--version', '-V', action='version', version=f'%(prog)s v{VERSION}') arg_namespace = parser.parse_args(args) if arg_namespace.verbose: log_level = logging.DEBUG else: log_level = getattr(logging, arg_namespace.verbosity.upper()) hdlr = logging.StreamHandler() hdlr.setLevel(log_level) hdlr.setFormatter(logging.Formatter(fmt='[%(asctime)s] %(message)s', datefmt='%H:%M:%S')) wg_logger = logging.getLogger('watchfiles') wg_logger.addHandler(hdlr) wg_logger.setLevel(log_level) if arg_namespace.target_type == 'auto': target_type = detect_target_type(arg_namespace.target) else: target_type = arg_namespace.target_type if target_type == 'function': logger.debug('target_type=function, attempting import of "%s"', arg_namespace.target) import_exit(arg_namespace.target) if arg_namespace.args: sys.argv = [arg_namespace.target] + shlex.split(arg_namespace.args) elif arg_namespace.args: logger.warning('--args is only used when the target is a function') try: paths = [resolve_path(p) for p in arg_namespace.paths] except FileNotFoundError as e: print(f'path "{e}" does not exist', file=sys.stderr) sys.exit(1) watch_filter, watch_filter_str = build_filter(arg_namespace.filter, arg_namespace.ignore_paths) logger.info( 'watchfiles v%s 👀 path=%s target="%s" (%s) filter=%s...', VERSION, ', '.join(f'"{p}"' for p in paths), arg_namespace.target, target_type, watch_filter_str, ) run_process( *paths, target=arg_namespace.target, target_type=target_type, watch_filter=watch_filter, debug=log_level == logging.DEBUG, sigint_timeout=arg_namespace.sigint_timeout, sigkill_timeout=arg_namespace.sigkill_timeout, recursive=not arg_namespace.non_recursive, ignore_permission_denied=arg_namespace.ignore_permission_denied, grace_period=arg_namespace.grace_period, ) def import_exit(function_path: str) -> Any: cwd = os.getcwd() if cwd not in sys.path: sys.path.append(cwd) try: return import_string(function_path) except ImportError as e: print(f'ImportError: {e}', file=sys.stderr) sys.exit(1) def build_filter( filter_name: str, ignore_paths_str: Optional[str] ) -> Tuple[Union[None, DefaultFilter, Callable[[Change, str], bool]], str]: ignore_paths: List[Path] = [] if ignore_paths_str: ignore_paths = [Path(p).resolve() for p in ignore_paths_str.split(',')] if filter_name == 'default': return DefaultFilter(ignore_paths=ignore_paths), 'DefaultFilter' elif filter_name == 'python': return PythonFilter(ignore_paths=ignore_paths), 'PythonFilter' elif filter_name == 'all': if ignore_paths: logger.warning('"--ignore-paths" argument ignored as "all" filter was selected') return None, '(no filter)' watch_filter_cls = import_exit(filter_name) if isinstance(watch_filter_cls, type) and issubclass(watch_filter_cls, DefaultFilter): return watch_filter_cls(ignore_paths=ignore_paths), watch_filter_cls.__name__ if ignore_paths: logger.warning('"--ignore-paths" argument ignored as filter is not a subclass of DefaultFilter') if isinstance(watch_filter_cls, type) and issubclass(watch_filter_cls, BaseFilter): return watch_filter_cls(), watch_filter_cls.__name__ else: watch_filter = cast(Callable[[Change, str], bool], watch_filter_cls) return watch_filter, repr(watch_filter_cls) python-watchfiles-0.21.0/watchfiles/filters.py000066400000000000000000000120541451223651600214240ustar00rootroot00000000000000import logging import os import re from pathlib import Path from typing import TYPE_CHECKING, Optional, Sequence, Union __all__ = 'BaseFilter', 'DefaultFilter', 'PythonFilter' logger = logging.getLogger('watchfiles.watcher') if TYPE_CHECKING: from .main import Change class BaseFilter: """ Useful base class for creating filters. `BaseFilter` should be inherited and configured, rather than used directly. The class supports ignoring files in 3 ways: """ __slots__ = '_ignore_dirs', '_ignore_entity_regexes', '_ignore_paths' ignore_dirs: Sequence[str] = () """Full names of directories to ignore, an obvious example would be `.git`.""" ignore_entity_patterns: Sequence[str] = () """ Patterns of files or directories to ignore, these are compiled into regexes. "entity" here refers to the specific file or directory - basically the result of `path.split(os.sep)[-1]`, an obvious example would be `r'\\.py[cod]$'`. """ ignore_paths: Sequence[Union[str, Path]] = () """ Full paths to ignore, e.g. `/home/users/.cache` or `C:\\Users\\user\\.cache`. """ def __init__(self) -> None: self._ignore_dirs = set(self.ignore_dirs) self._ignore_entity_regexes = tuple(re.compile(r) for r in self.ignore_entity_patterns) self._ignore_paths = tuple(map(str, self.ignore_paths)) def __call__(self, change: 'Change', path: str) -> bool: """ Instances of `BaseFilter` subclasses can be used as callables. Args: change: The type of change that occurred, see [`Change`][watchfiles.Change]. path: the raw path of the file or directory that changed. Returns: True if the file should be included in changes, False if it should be ignored. """ parts = path.lstrip(os.sep).split(os.sep) if any(p in self._ignore_dirs for p in parts): return False entity_name = parts[-1] if any(r.search(entity_name) for r in self._ignore_entity_regexes): return False elif self._ignore_paths and path.startswith(self._ignore_paths): return False else: return True def __repr__(self) -> str: args = ', '.join(f'{k}={getattr(self, k, None)!r}' for k in self.__slots__) return f'{self.__class__.__name__}({args})' class DefaultFilter(BaseFilter): """ The default filter, which ignores files and directories that you might commonly want to ignore. """ ignore_dirs: Sequence[str] = ( '__pycache__', '.git', '.hg', '.svn', '.tox', '.venv', 'site-packages', '.idea', 'node_modules', '.mypy_cache', '.pytest_cache', '.hypothesis', ) """Directory names to ignore.""" ignore_entity_patterns: Sequence[str] = ( r'\.py[cod]$', r'\.___jb_...___$', r'\.sw.$', '~$', r'^\.\#', r'^\.DS_Store$', r'^flycheck_', ) """File/Directory name patterns to ignore.""" def __init__( self, *, ignore_dirs: Optional[Sequence[str]] = None, ignore_entity_patterns: Optional[Sequence[str]] = None, ignore_paths: Optional[Sequence[Union[str, Path]]] = None, ) -> None: """ Args: ignore_dirs: if not `None`, overrides the `ignore_dirs` value set on the class. ignore_entity_patterns: if not `None`, overrides the `ignore_entity_patterns` value set on the class. ignore_paths: if not `None`, overrides the `ignore_paths` value set on the class. """ if ignore_dirs is not None: self.ignore_dirs = ignore_dirs if ignore_entity_patterns is not None: self.ignore_entity_patterns = ignore_entity_patterns if ignore_paths is not None: self.ignore_paths = ignore_paths super().__init__() class PythonFilter(DefaultFilter): """ A filter for Python files, since this class inherits from [`DefaultFilter`][watchfiles.DefaultFilter] it will ignore files and directories that you might commonly want to ignore as well as filtering out all changes except in Python files (files with extensions `('.py', '.pyx', '.pyd')`). """ def __init__( self, *, ignore_paths: Optional[Sequence[Union[str, Path]]] = None, extra_extensions: Sequence[str] = (), ) -> None: """ Args: ignore_paths: The paths to ignore, see [`BaseFilter`][watchfiles.BaseFilter]. extra_extensions: extra extensions to ignore. `ignore_paths` and `extra_extensions` can be passed as arguments partly to support [CLI](../cli.md) usage where `--ignore-paths` and `--extensions` can be passed as arguments. """ self.extensions = ('.py', '.pyx', '.pyd') + tuple(extra_extensions) super().__init__(ignore_paths=ignore_paths) def __call__(self, change: 'Change', path: str) -> bool: return path.endswith(self.extensions) and super().__call__(change, path) python-watchfiles-0.21.0/watchfiles/main.py000066400000000000000000000333331451223651600207030ustar00rootroot00000000000000import logging import os import sys import warnings from enum import IntEnum from pathlib import Path from typing import TYPE_CHECKING, AsyncGenerator, Callable, Generator, Optional, Set, Tuple, Union import anyio from ._rust_notify import RustNotify from .filters import DefaultFilter __all__ = 'watch', 'awatch', 'Change', 'FileChange' logger = logging.getLogger('watchfiles.main') class Change(IntEnum): """ Enum representing the type of change that occurred. """ added = 1 """A new file or directory was added.""" modified = 2 """A file or directory was modified, can be either a metadata or data change.""" deleted = 3 """A file or directory was deleted.""" def raw_str(self) -> str: return self.name FileChange = Tuple[Change, str] """ A tuple representing a file change, first element is a [`Change`][watchfiles.Change] member, second is the path of the file or directory that changed. """ if TYPE_CHECKING: import asyncio from typing import Protocol import trio AnyEvent = Union[anyio.Event, asyncio.Event, trio.Event] class AbstractEvent(Protocol): def is_set(self) -> bool: ... def watch( *paths: Union[Path, str], watch_filter: Optional[Callable[['Change', str], bool]] = DefaultFilter(), debounce: int = 1_600, step: int = 50, stop_event: Optional['AbstractEvent'] = None, rust_timeout: int = 5_000, yield_on_timeout: bool = False, debug: bool = False, raise_interrupt: bool = True, force_polling: Optional[bool] = None, poll_delay_ms: int = 300, recursive: bool = True, ignore_permission_denied: Optional[bool] = None, ) -> Generator[Set[FileChange], None, None]: """ Watch one or more paths and yield a set of changes whenever files change. The paths watched can be directories or files, directories are watched recursively - changes in subdirectories are also detected. #### Force polling Notify will fall back to file polling if it can't use file system notifications, but we also force notify to us polling if the `force_polling` argument is `True`; if `force_polling` is unset (or `None`), we enable force polling thus: * if the `WATCHFILES_FORCE_POLLING` environment variable exists and is not empty: * if the value is `false`, `disable` or `disabled`, force polling is disabled * otherwise, force polling is enabled * otherwise, we enable force polling only if we detect we're running on WSL (Windows Subsystem for Linux) Args: *paths: filesystem paths to watch. watch_filter: callable used to filter out changes which are not important, you can either use a raw callable or a [`BaseFilter`][watchfiles.BaseFilter] instance, defaults to an instance of [`DefaultFilter`][watchfiles.DefaultFilter]. To keep all changes, use `None`. debounce: maximum time in milliseconds to group changes over before yielding them. step: time to wait for new changes in milliseconds, if no changes are detected in this time, and at least one change has been detected, the changes are yielded. stop_event: event to stop watching, if this is set, the generator will stop iteration, this can be anything with an `is_set()` method which returns a bool, e.g. `threading.Event()`. rust_timeout: maximum time in milliseconds to wait in the rust code for changes, `0` means no timeout. yield_on_timeout: if `True`, the generator will yield upon timeout in rust even if no changes are detected. debug: whether to print information about all filesystem changes in rust to stdout. raise_interrupt: whether to re-raise `KeyboardInterrupt`s, or suppress the error and just stop iterating. force_polling: See [Force polling](#force-polling) above. poll_delay_ms: delay between polling for changes, only used if `force_polling=True`. recursive: if `True`, watch for changes in sub-directories recursively, otherwise watch only for changes in the top-level directory, default is `True`. ignore_permission_denied: if `True`, will ignore permission denied errors, otherwise will raise them by default. Setting the `WATCHFILES_IGNORE_PERMISSION_DENIED` environment variable will set this value too. Yields: The generator yields sets of [`FileChange`][watchfiles.main.FileChange]s. ```py title="Example of watch usage" from watchfiles import watch for changes in watch('./first/dir', './second/dir', raise_interrupt=False): print(changes) ``` """ force_polling = _default_force_polling(force_polling) ignore_permission_denied = _default_ignore_permission_denied(ignore_permission_denied) with RustNotify( [str(p) for p in paths], debug, force_polling, poll_delay_ms, recursive, ignore_permission_denied ) as watcher: while True: raw_changes = watcher.watch(debounce, step, rust_timeout, stop_event) if raw_changes == 'timeout': if yield_on_timeout: yield set() else: logger.debug('rust notify timeout, continuing') elif raw_changes == 'signal': if raise_interrupt: raise KeyboardInterrupt else: logger.warning('KeyboardInterrupt caught, stopping watch') return elif raw_changes == 'stop': return else: changes = _prep_changes(raw_changes, watch_filter) if changes: _log_changes(changes) yield changes else: logger.debug('all changes filtered out, raw_changes=%s', raw_changes) async def awatch( # noqa C901 *paths: Union[Path, str], watch_filter: Optional[Callable[[Change, str], bool]] = DefaultFilter(), debounce: int = 1_600, step: int = 50, stop_event: Optional['AnyEvent'] = None, rust_timeout: Optional[int] = None, yield_on_timeout: bool = False, debug: bool = False, raise_interrupt: Optional[bool] = None, force_polling: Optional[bool] = None, poll_delay_ms: int = 300, recursive: bool = True, ignore_permission_denied: Optional[bool] = None, ) -> AsyncGenerator[Set[FileChange], None]: """ Asynchronous equivalent of [`watch`][watchfiles.watch] using threads to wait for changes. Arguments match those of [`watch`][watchfiles.watch] except `stop_event`. All async methods use [anyio](https://anyio.readthedocs.io/en/latest/) to run the event loop. Unlike [`watch`][watchfiles.watch] `KeyboardInterrupt` cannot be suppressed by `awatch` so they need to be caught where `asyncio.run` or equivalent is called. Args: *paths: filesystem paths to watch. watch_filter: matches the same argument of [`watch`][watchfiles.watch]. debounce: matches the same argument of [`watch`][watchfiles.watch]. step: matches the same argument of [`watch`][watchfiles.watch]. stop_event: `anyio.Event` which can be used to stop iteration, see example below. rust_timeout: matches the same argument of [`watch`][watchfiles.watch], except that `None` means use `1_000` on Windows and `5_000` on other platforms thus helping with exiting on `Ctrl+C` on Windows, see [#110](https://github.com/samuelcolvin/watchfiles/issues/110). yield_on_timeout: matches the same argument of [`watch`][watchfiles.watch]. debug: matches the same argument of [`watch`][watchfiles.watch]. raise_interrupt: This is deprecated, `KeyboardInterrupt` will cause this coroutine to be cancelled and then be raised by the top level `asyncio.run` call or equivalent, and should be caught there. See [#136](https://github.com/samuelcolvin/watchfiles/issues/136) force_polling: if true, always use polling instead of file system notifications, default is `None` where `force_polling` is set to `True` if the `WATCHFILES_FORCE_POLLING` environment variable exists. poll_delay_ms: delay between polling for changes, only used if `force_polling=True`. recursive: if `True`, watch for changes in sub-directories recursively, otherwise watch only for changes in the top-level directory, default is `True`. ignore_permission_denied: if `True`, will ignore permission denied errors, otherwise will raise them by default. Setting the `WATCHFILES_IGNORE_PERMISSION_DENIED` environment variable will set this value too. Yields: The generator yields sets of [`FileChange`][watchfiles.main.FileChange]s. ```py title="Example of awatch usage" import asyncio from watchfiles import awatch async def main(): async for changes in awatch('./first/dir', './second/dir'): print(changes) if __name__ == '__main__': try: asyncio.run(main()) except KeyboardInterrupt: print('stopped via KeyboardInterrupt') ``` ```py title="Example of awatch usage with a stop event" import asyncio from watchfiles import awatch async def main(): stop_event = asyncio.Event() async def stop_soon(): await asyncio.sleep(3) stop_event.set() stop_soon_task = asyncio.create_task(stop_soon()) async for changes in awatch('/path/to/dir', stop_event=stop_event): print(changes) # cleanup by awaiting the (now complete) stop_soon_task await stop_soon_task asyncio.run(main()) ``` """ if raise_interrupt is not None: warnings.warn( 'raise_interrupt is deprecated, KeyboardInterrupt will cause this coroutine to be cancelled and then ' 'be raised by the top level asyncio.run call or equivalent, and should be caught there. See #136.', DeprecationWarning, ) if stop_event is None: stop_event_: 'AnyEvent' = anyio.Event() else: stop_event_ = stop_event force_polling = _default_force_polling(force_polling) ignore_permission_denied = _default_ignore_permission_denied(ignore_permission_denied) with RustNotify( [str(p) for p in paths], debug, force_polling, poll_delay_ms, recursive, ignore_permission_denied ) as watcher: timeout = _calc_async_timeout(rust_timeout) CancelledError = anyio.get_cancelled_exc_class() while True: async with anyio.create_task_group() as tg: try: raw_changes = await anyio.to_thread.run_sync(watcher.watch, debounce, step, timeout, stop_event_) except (CancelledError, KeyboardInterrupt): stop_event_.set() # suppressing KeyboardInterrupt wouldn't stop it getting raised by the top level asyncio.run call raise tg.cancel_scope.cancel() if raw_changes == 'timeout': if yield_on_timeout: yield set() else: logger.debug('rust notify timeout, continuing') elif raw_changes == 'stop': return elif raw_changes == 'signal': # in theory the watch thread should never get a signal raise RuntimeError('watch thread unexpectedly received a signal') else: changes = _prep_changes(raw_changes, watch_filter) if changes: _log_changes(changes) yield changes else: logger.debug('all changes filtered out, raw_changes=%s', raw_changes) def _prep_changes( raw_changes: Set[Tuple[int, str]], watch_filter: Optional[Callable[[Change, str], bool]] ) -> Set[FileChange]: # if we wanted to be really snazzy, we could move this into rust changes = {(Change(change), path) for change, path in raw_changes} if watch_filter: changes = {c for c in changes if watch_filter(c[0], c[1])} return changes def _log_changes(changes: Set[FileChange]) -> None: if logger.isEnabledFor(logging.INFO): # pragma: no branch count = len(changes) plural = '' if count == 1 else 's' if logger.isEnabledFor(logging.DEBUG): logger.debug('%d change%s detected: %s', count, plural, changes) else: logger.info('%d change%s detected', count, plural) def _calc_async_timeout(timeout: Optional[int]) -> int: """ see https://github.com/samuelcolvin/watchfiles/issues/110 """ if timeout is None: if sys.platform == 'win32': return 1_000 else: return 5_000 else: return timeout def _default_force_polling(force_polling: Optional[bool]) -> bool: """ See docstring for `watch` above for details. See samuelcolvin/watchfiles#167 and samuelcolvin/watchfiles#187 for discussion and rationale. """ if force_polling is not None: return force_polling env_var = os.getenv('WATCHFILES_FORCE_POLLING') if env_var: return env_var.lower() not in {'false', 'disable', 'disabled'} else: return _auto_force_polling() def _auto_force_polling() -> bool: """ Whether to auto-enable force polling, it should be enabled automatically only on WSL. See samuelcolvin/watchfiles#187 for discussion. """ import platform uname = platform.uname() return 'microsoft-standard' in uname.release.lower() and uname.system.lower() == 'linux' def _default_ignore_permission_denied(ignore_permission_denied: Optional[bool]) -> bool: if ignore_permission_denied is not None: return ignore_permission_denied env_var = os.getenv('WATCHFILES_IGNORE_PERMISSION_DENIED') return bool(env_var) python-watchfiles-0.21.0/watchfiles/py.typed000066400000000000000000000001051451223651600210730ustar00rootroot00000000000000# Marker file for PEP 561. The watchfiles package uses inline types. python-watchfiles-0.21.0/watchfiles/run.py000066400000000000000000000361151451223651600205640ustar00rootroot00000000000000import contextlib import json import logging import os import re import shlex import signal import subprocess import sys from importlib import import_module from multiprocessing import get_context from multiprocessing.context import SpawnProcess from pathlib import Path from time import sleep from typing import TYPE_CHECKING, Any, Callable, Dict, Generator, List, Optional, Set, Tuple, Union import anyio from .filters import DefaultFilter from .main import Change, FileChange, awatch, watch if TYPE_CHECKING: try: from typing import Literal except ImportError: from typing_extensions import Literal # type: ignore[misc] __all__ = 'run_process', 'arun_process', 'detect_target_type', 'import_string' logger = logging.getLogger('watchfiles.main') def run_process( *paths: Union[Path, str], target: Union[str, Callable[..., Any]], args: Tuple[Any, ...] = (), kwargs: Optional[Dict[str, Any]] = None, target_type: "Literal['function', 'command', 'auto']" = 'auto', callback: Optional[Callable[[Set[FileChange]], None]] = None, watch_filter: Optional[Callable[[Change, str], bool]] = DefaultFilter(), grace_period: float = 0, debounce: int = 1_600, step: int = 50, debug: bool = False, sigint_timeout: int = 5, sigkill_timeout: int = 1, recursive: bool = True, ignore_permission_denied: bool = False, ) -> int: """ Run a process and restart it upon file changes. `run_process` can work in two ways: * Using `multiprocessing.Process` † to run a python function * Or, using `subprocess.Popen` to run a command !!! note **†** technically `multiprocessing.get_context('spawn').Process` to avoid forking and improve code reload/import. Internally, `run_process` uses [`watch`][watchfiles.watch] with `raise_interrupt=False` so the function exits cleanly upon `Ctrl+C`. Args: *paths: matches the same argument of [`watch`][watchfiles.watch] target: function or command to run args: arguments to pass to `target`, only used if `target` is a function kwargs: keyword arguments to pass to `target`, only used if `target` is a function target_type: type of target. Can be `'function'`, `'command'`, or `'auto'` in which case [`detect_target_type`][watchfiles.run.detect_target_type] is used to determine the type. callback: function to call on each reload, the function should accept a set of changes as the sole argument watch_filter: matches the same argument of [`watch`][watchfiles.watch] grace_period: number of seconds after the process is started before watching for changes debounce: matches the same argument of [`watch`][watchfiles.watch] step: matches the same argument of [`watch`][watchfiles.watch] debug: matches the same argument of [`watch`][watchfiles.watch] sigint_timeout: the number of seconds to wait after sending sigint before sending sigkill sigkill_timeout: the number of seconds to wait after sending sigkill before raising an exception recursive: matches the same argument of [`watch`][watchfiles.watch] Returns: number of times the function was reloaded. ```py title="Example of run_process running a function" from watchfiles import run_process def callback(changes): print('changes detected:', changes) def foobar(a, b): print('foobar called with:', a, b) if __name__ == '__main__': run_process('./path/to/dir', target=foobar, args=(1, 2), callback=callback) ``` As well as using a `callback` function, changes can be accessed from within the target function, using the `WATCHFILES_CHANGES` environment variable. ```py title="Example of run_process accessing changes" from watchfiles import run_process def foobar(a, b, c): # changes will be an empty list "[]" the first time the function is called changes = os.getenv('WATCHFILES_CHANGES') changes = json.loads(changes) print('foobar called due to changes:', changes) if __name__ == '__main__': run_process('./path/to/dir', target=foobar, args=(1, 2, 3)) ``` Again with the target as `command`, `WATCHFILES_CHANGES` can be used to access changes. ```bash title="example.sh" echo "changers: ${WATCHFILES_CHANGES}" ``` ```py title="Example of run_process running a command" from watchfiles import run_process if __name__ == '__main__': run_process('.', target='./example.sh') ``` """ if target_type == 'auto': target_type = detect_target_type(target) logger.debug('running "%s" as %s', target, target_type) catch_sigterm() process = start_process(target, target_type, args, kwargs) reloads = 0 if grace_period: logger.debug('sleeping for %s seconds before watching for changes', grace_period) sleep(grace_period) try: for changes in watch( *paths, watch_filter=watch_filter, debounce=debounce, step=step, debug=debug, raise_interrupt=False, recursive=recursive, ignore_permission_denied=ignore_permission_denied, ): callback and callback(changes) process.stop(sigint_timeout=sigint_timeout, sigkill_timeout=sigkill_timeout) process = start_process(target, target_type, args, kwargs, changes) reloads += 1 finally: process.stop() return reloads async def arun_process( *paths: Union[Path, str], target: Union[str, Callable[..., Any]], args: Tuple[Any, ...] = (), kwargs: Optional[Dict[str, Any]] = None, target_type: "Literal['function', 'command', 'auto']" = 'auto', callback: Optional[Callable[[Set[FileChange]], Any]] = None, watch_filter: Optional[Callable[[Change, str], bool]] = DefaultFilter(), grace_period: float = 0, debounce: int = 1_600, step: int = 50, debug: bool = False, recursive: bool = True, ignore_permission_denied: bool = False, ) -> int: """ Async equivalent of [`run_process`][watchfiles.run_process], all arguments match those of `run_process` except `callback` which can be a coroutine. Starting and stopping the process and watching for changes is done in a separate thread. As with `run_process`, internally `arun_process` uses [`awatch`][watchfiles.awatch], however `KeyboardInterrupt` cannot be caught and suppressed in `awatch` so these errors need to be caught separately, see below. ```py title="Example of arun_process usage" import asyncio from watchfiles import arun_process async def callback(changes): await asyncio.sleep(0.1) print('changes detected:', changes) def foobar(a, b): print('foobar called with:', a, b) async def main(): await arun_process('.', target=foobar, args=(1, 2), callback=callback) if __name__ == '__main__': try: asyncio.run(main()) except KeyboardInterrupt: print('stopped via KeyboardInterrupt') ``` """ import inspect if target_type == 'auto': target_type = detect_target_type(target) logger.debug('running "%s" as %s', target, target_type) catch_sigterm() process = await anyio.to_thread.run_sync(start_process, target, target_type, args, kwargs) reloads = 0 if grace_period: logger.debug('sleeping for %s seconds before watching for changes', grace_period) await anyio.sleep(grace_period) async for changes in awatch( *paths, watch_filter=watch_filter, debounce=debounce, step=step, debug=debug, recursive=recursive, ignore_permission_denied=ignore_permission_denied, ): if callback is not None: r = callback(changes) if inspect.isawaitable(r): await r await anyio.to_thread.run_sync(process.stop) process = await anyio.to_thread.run_sync(start_process, target, target_type, args, kwargs, changes) reloads += 1 await anyio.to_thread.run_sync(process.stop) return reloads # Use spawn context to make sure code run in subprocess # does not reuse imported modules in main process/context spawn_context = get_context('spawn') def split_cmd(cmd: str) -> List[str]: import platform posix = platform.uname().system.lower() != 'windows' return shlex.split(cmd, posix=posix) def start_process( target: Union[str, Callable[..., Any]], target_type: "Literal['function', 'command']", args: Tuple[Any, ...], kwargs: Optional[Dict[str, Any]], changes: Optional[Set[FileChange]] = None, ) -> 'CombinedProcess': if changes is None: changes_env_var = '[]' else: changes_env_var = json.dumps([[c.raw_str(), p] for c, p in changes]) os.environ['WATCHFILES_CHANGES'] = changes_env_var process: 'Union[SpawnProcess, subprocess.Popen[bytes]]' if target_type == 'function': kwargs = kwargs or {} if isinstance(target, str): args = target, get_tty_path(), args, kwargs target_ = run_function kwargs = {} else: target_ = target process = spawn_context.Process(target=target_, args=args, kwargs=kwargs) process.start() else: if args or kwargs: logger.warning('ignoring args and kwargs for "command" target') assert isinstance(target, str), 'target must be a string to run as a command' popen_args = split_cmd(target) process = subprocess.Popen(popen_args) return CombinedProcess(process) def detect_target_type(target: Union[str, Callable[..., Any]]) -> "Literal['function', 'command']": """ Used by [`run_process`][watchfiles.run_process], [`arun_process`][watchfiles.arun_process] and indirectly the CLI to determine the target type with `target_type` is `auto`. Detects the target type - either `function` or `command`. This method is only called with `target_type='auto'`. The following logic is employed: * If `target` is not a string, it is assumed to be a function * If `target` ends with `.py` or `.sh`, it is assumed to be a command * Otherwise, the target is assumed to be a function if it matches the regex `[a-zA-Z0-9_]+(\\.[a-zA-Z0-9_]+)+` If this logic does not work for you, specify the target type explicitly using the `target_type` function argument or `--target-type` command line argument. Args: target: The target value Returns: either `'function'` or `'command'` """ if not isinstance(target, str): return 'function' elif target.endswith(('.py', '.sh')): return 'command' elif re.fullmatch(r'[a-zA-Z0-9_]+(\.[a-zA-Z0-9_]+)+', target): return 'function' else: return 'command' class CombinedProcess: def __init__(self, p: 'Union[SpawnProcess, subprocess.Popen[bytes]]'): self._p = p assert self.pid is not None, 'process not yet spawned' def stop(self, sigint_timeout: int = 5, sigkill_timeout: int = 1) -> None: os.environ.pop('WATCHFILES_CHANGES', None) if self.is_alive(): logger.debug('stopping process...') os.kill(self.pid, signal.SIGINT) try: self.join(sigint_timeout) except subprocess.TimeoutExpired: # Capture this exception to allow the self.exitcode to be reached. # This will allow the SIGKILL to be sent, otherwise it is swallowed up. logger.warning('SIGINT timed out after %r seconds', sigint_timeout) pass if self.exitcode is None: logger.warning('process has not terminated, sending SIGKILL') os.kill(self.pid, signal.SIGKILL) self.join(sigkill_timeout) else: logger.debug('process stopped') else: logger.warning('process already dead, exit code: %d', self.exitcode) def is_alive(self) -> bool: if isinstance(self._p, SpawnProcess): return self._p.is_alive() else: return self._p.poll() is None @property def pid(self) -> int: # we check the process has always been spawned when CombinedProcess is initialised return self._p.pid # type: ignore[return-value] def join(self, timeout: int) -> None: if isinstance(self._p, SpawnProcess): self._p.join(timeout) else: self._p.wait(timeout) @property def exitcode(self) -> Optional[int]: if isinstance(self._p, SpawnProcess): return self._p.exitcode else: return self._p.returncode def run_function(function: str, tty_path: Optional[str], args: Tuple[Any, ...], kwargs: Dict[str, Any]) -> None: with set_tty(tty_path): func = import_string(function) func(*args, **kwargs) def import_string(dotted_path: str) -> Any: """ Stolen approximately from django. Import a dotted module path and return the attribute/class designated by the last name in the path. Raise ImportError if the import fails. """ try: module_path, class_name = dotted_path.strip(' ').rsplit('.', 1) except ValueError as e: raise ImportError(f'"{dotted_path}" doesn\'t look like a module path') from e module = import_module(module_path) try: return getattr(module, class_name) except AttributeError as e: raise ImportError(f'Module "{module_path}" does not define a "{class_name}" attribute') from e def get_tty_path() -> Optional[str]: # pragma: no cover """ Return the path to the current TTY, if any. Virtually impossible to test in pytest, hence no cover. """ try: return os.ttyname(sys.stdin.fileno()) except OSError: # fileno() always fails with pytest return '/dev/tty' except AttributeError: # on Windows. No idea of a better solution return None @contextlib.contextmanager def set_tty(tty_path: Optional[str]) -> Generator[None, None, None]: if tty_path: try: with open(tty_path) as tty: # pragma: no cover sys.stdin = tty yield except OSError: # eg. "No such device or address: '/dev/tty'", see https://github.com/samuelcolvin/watchfiles/issues/40 yield else: # currently on windows tty_path is None and there's nothing we can do here yield def raise_keyboard_interrupt(signum: int, _frame: Any) -> None: # pragma: no cover logger.warning('received signal %s, raising KeyboardInterrupt', signal.Signals(signum)) raise KeyboardInterrupt def catch_sigterm() -> None: """ Catch SIGTERM and raise KeyboardInterrupt instead. This means watchfiles will stop quickly on `docker compose stop` and other cases where SIGTERM is sent. Without this the watchfiles process will be killed while a running process will continue uninterrupted. """ logger.debug('registering handler for SIGTERM on watchfiles process %d', os.getpid()) signal.signal(signal.SIGTERM, raise_keyboard_interrupt) python-watchfiles-0.21.0/watchfiles/version.py000066400000000000000000000001251451223651600214350ustar00rootroot00000000000000from ._rust_notify import __version__ __all__ = ('VERSION',) VERSION = __version__