├── .gitignore ├── LICENSE ├── Pipfile ├── Pipfile.lock ├── README.md ├── build.sh ├── recursive-gobuster.pyz └── recursive-gobuster ├── __main__.py └── pyinotify.py /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | wheels/ 23 | share/python-wheels/ 24 | *.egg-info/ 25 | .installed.cfg 26 | *.egg 27 | MANIFEST 28 | 29 | # PyInstaller 30 | # Usually these files are written by a python script from a template 31 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 32 | *.manifest 33 | *.spec 34 | 35 | # Installer logs 36 | pip-log.txt 37 | pip-delete-this-directory.txt 38 | 39 | # Unit test / coverage reports 40 | htmlcov/ 41 | .tox/ 42 | .nox/ 43 | .coverage 44 | .coverage.* 45 | .cache 46 | nosetests.xml 47 | coverage.xml 48 | *.cover 49 | .hypothesis/ 50 | .pytest_cache/ 51 | 52 | # Translations 53 | *.mo 54 | *.pot 55 | 56 | # Django stuff: 57 | *.log 58 | local_settings.py 59 | db.sqlite3 60 | 61 | # Flask stuff: 62 | instance/ 63 | .webassets-cache 64 | 65 | # Scrapy stuff: 66 | .scrapy 67 | 68 | # Sphinx documentation 69 | docs/_build/ 70 | 71 | # PyBuilder 72 | target/ 73 | 74 | # Jupyter Notebook 75 | .ipynb_checkpoints 76 | 77 | # IPython 78 | profile_default/ 79 | ipython_config.py 80 | 81 | # pyenv 82 | .python-version 83 | 84 | # celery beat schedule file 85 | celerybeat-schedule 86 | 87 | # SageMath parsed files 88 | *.sage.py 89 | 90 | # Environments 91 | .env 92 | .venv 93 | env/ 94 | venv/ 95 | ENV/ 96 | env.bak/ 97 | venv.bak/ 98 | 99 | # Spyder project settings 100 | .spyderproject 101 | .spyproject 102 | 103 | # Rope project settings 104 | .ropeproject 105 | 106 | .idea/ 107 | 108 | # mkdocs documentation 109 | /site 110 | 111 | # mypy 112 | .mypy_cache/ 113 | .dmypy.json 114 | dmypy.json 115 | 116 | # Pyre type checker 117 | .pyre/ -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019 epi 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /Pipfile: -------------------------------------------------------------------------------- 1 | [[source]] 2 | url = "https://pypi.python.org/simple" 3 | verify_ssl = true 4 | name = "pypi" 5 | 6 | [dev-packages] 7 | 8 | [packages] 9 | pyinotify = "*" 10 | 11 | [requires] 12 | python_version = "3.6" 13 | -------------------------------------------------------------------------------- /Pipfile.lock: -------------------------------------------------------------------------------- 1 | { 2 | "_meta": { 3 | "hash": { 4 | "sha256": "c3d45c7f1329883ea5d0cbbe478992de7452e5d5bfc74cfc02c850b3a607e842" 5 | }, 6 | "pipfile-spec": 6, 7 | "requires": { 8 | "python_version": "3.6" 9 | }, 10 | "sources": [ 11 | { 12 | "name": "pypi", 13 | "url": "https://pypi.python.org/simple", 14 | "verify_ssl": true 15 | } 16 | ] 17 | }, 18 | "default": { 19 | "pyinotify": { 20 | "hashes": [ 21 | "sha256:9c998a5d7606ca835065cdabc013ae6c66eb9ea76a00a1e3bc6e0cfe2b4f71f4" 22 | ], 23 | "index": "pypi", 24 | "version": "==0.9.6" 25 | } 26 | }, 27 | "develop": {} 28 | } 29 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ![Code style: black](https://img.shields.io/badge/code%20style-black-000000.svg) 2 | 3 | # DEPRECATED 4 | # DEPRECATED 5 | # DEPRECATED 6 | 7 | This tool has been deprecated in favor of my new project [feroxbuster](https://github.com/epi052/feroxbuster). Feroxbuster 8 | is recursive, written in rust, and fast. It does all the things that `recursive-gobuster` does in addition to quite a 9 | bit more. If you came here looking for a recursive content discovery tool, please check out [feroxbuster](https://github.com/epi052/feroxbuster). 10 | 11 | # DEPRECATED 12 | # DEPRECATED 13 | # DEPRECATED 14 | 15 | # recursive-gobuster 16 | A wrapper around [gobuster](https://github.com/OJ/gobuster) that automatically scans newly discovered directories. 17 | 18 | ## Why though? 19 | 20 | @OJ designed gobuster to not be recursive. In fact, it's [#3](https://github.com/OJ/gobuster#oh-dear-god-why) in the list of reasons describing why he wrote gobuster in the first place. He's a much smarter man than I and I'm sure his reasoning for not including the capability is sound, but I wanted the ability to kick off a scan with my favorite scanner and walk away. 21 | 22 | I started off looking for something ready-made. I found and really liked [Ryan Wendel's](http://www.ryanwendel.com/2017/08/06/recursive-gobuster-script/) shell script to accomplish recursive gobuster scanning. However, there were a few minor behavioral things I wanted to tweak. I also wanted to speed up execution a bit. His original script is what prompted me to write this one. 23 | 24 | ## Dependencies 25 | 26 | - [pyinotify](https://github.com/seb-m/pyinotify) 27 | - python >= 3.6 28 | 29 | ## Install 30 | 31 | `recursive-gobuster` is distributed with a pre-packaged executable zip file. This way, those who want to use this tool can do so without needing to manage their own virtualenv. 32 | 33 | ``` 34 | git clone https://github.com/epi052/recursive-gobuster.git 35 | 36 | ./recursive-gobuster/recursive-gobuster.pyz 37 | usage: recursive-gobuster.pyz [-h] [-t THREADS] [-x EXTENSIONS] [-w WORDLIST] 38 | [-d] [-U USER] [-P PASSWORD] [-p PROXY] [-s] 39 | target 40 | ``` 41 | 42 | Optionally, set it somewhere in your PATH 43 | 44 | `sudo ln -s $(pwd)/recursive-gobuster/recursive-gobuster.pyz /usr/local/bin` 45 | 46 | ## Build 47 | There is a `build.sh` script included that performs the following actions: 48 | 49 | - installs [pyinotify](https://github.com/seb-m/pyinotify) into the `recursive-gobuster` directory 50 | - removes pip metadata 51 | - creates an executable zip using python's [zipapp](https://docs.python.org/3/library/zipapp.html) library 52 | - ensures the executable zip has the executable bit set 53 | 54 | Since there is a prebuilt package included in the repo, the build script is primarily included in case you want to modify the functionality of the script and afterwards would like to rebuild the executable zip. That workflow would look something like this: 55 | 56 | 1. modify `recursive-gobuster/__main__.py` to your liking 57 | 2. delete or backup current `recursive-gobuster.pyz` 58 | 3. run `build.sh` 59 | 60 | ## Tool Behavior 61 | 62 | `recursive-gobuster` isn't recursive in the sense of how it works, only in that it will continue scanning directories until there are no more identified. These are the high-level steps it takes to scan: 63 | 64 | 1. Creates a temporary directory and sets an `inotify` watch on the same directory. 65 | 2. Begins a `gobuster` scan against the specified _target url_. 66 | 3. Defines the scan's output file to be located within the watched directory. 67 | 4. Watches the initial (and all subsequent) scan output file for two filesystem events [IN_MODIFY](https://github.com/seb-m/pyinotify/wiki/Events-types) and [IN_DELETE](https://github.com/seb-m/pyinotify/wiki/Events-types). Each time a file is **modified**, any new sub-directory identified is used as the _target url_ for a new scan. The new scans begin as soon as a new sub-directory is seen (i.e. asynchronously). When a file is **closed**, it signifies the end of that particular scan. 68 | 5. Cleans up the temporary directory when all scans are complete. 69 | 6. Creates a sorted log file of all results in the current working directory. 70 | 71 | ## Considerations 72 | I wrote this tool for me, to be integrated into my workflow with my preferences in mind. As such, there are some things to be aware of that you may or may not care for. 73 | 74 | - There is no limit placed on the number of concurrent scans and no option to do rate limiting. Each one is its own separate process, however this behavior may not be desirable, depending on your target and host machine. 75 | - The tool is pretty opinionated about options fed to gobuster. Removing the `-e`, or `-n` will likely break functionality of the tool. The other options are either configurable via command line options, or can be manipulated in the source code without any adverse side-effects 76 | 77 | | hard-coded options | meaning | 78 | |----|--------------------------------------------------| 79 | | -q | Don't print the banner and other noise | 80 | | -e | Expanded mode, print full URLs | 81 | | -k | Skip SSL certificate verification | 82 | 83 | Knowing all that, it's still just a python script and can easily be manipulated to your own preferences. I've included `build.sh` so you can make changes and easily generate a new packaged version. 84 | 85 | ## Examples 86 | 87 | ``` 88 | usage: recursive-gobuster.pyz [-h] [-t THREADS] [-x EXTENSIONS] [-w WORDLIST] 89 | [-d] [-U USER] [-P PASSWORD] [-p PROXY] [-s] 90 | target 91 | 92 | positional arguments: 93 | target target to scan 94 | 95 | optional arguments: 96 | -h, --help show this help message and exit 97 | -t THREADS, --threads THREADS 98 | # of threads for each spawned gobuster (default: 20) 99 | -x EXTENSIONS, --extensions EXTENSIONS 100 | extensions passed to the -x option for spawned 101 | gobuster 102 | -w WORDLIST, --wordlist WORDLIST 103 | wordlist for each spawned gobuster (default: 104 | /usr/share/seclists/Discovery/Web-Content/common.txt) 105 | -d, --devnull send stderr to devnull 106 | -U USER, --user USER Username for Basic Auth (dir mode only) 107 | -P PASSWORD, --password PASSWORD 108 | Password for Basic Auth (dir mode only) 109 | -p PROXY, --proxy PROXY 110 | Proxy to use for requests [http(s)://host:port] (dir 111 | mode only) 112 | -s, --status Include status code reporting (default: false) 113 | ``` 114 | 115 | ### Standard run with defaults 116 | The default wordlist is `seclists/Discovery/Web-Content/common.txt`. The default number of threads is `20`. There are no extensions by default. 117 | 118 | ``` 119 | time /opt/recursive_gobuster.pyz http://10.10.10.112 120 | http://10.10.10.112/.hta/ 121 | http://10.10.10.112/.htpasswd/ 122 | http://10.10.10.112/.htaccess/ 123 | 2019/01/17 05:49:34 [-] Wildcard response found: http://10.10.10.112/.htpasswd/a1612d11-2fda-486f-a039-d5aef1556386 => 403 124 | 2019/01/17 05:49:34 [!] To force processing of Wildcard responses, specify the '-fw' switch. 125 | 2019/01/17 05:49:34 [-] Wildcard response found: http://10.10.10.112/.hta/b2b5e936-7574-41c8-bb60-022c81b0b325 => 403 126 | 2019/01/17 05:49:34 [!] To force processing of Wildcard responses, specify the '-fw' switch. 127 | 2019/01/17 05:49:34 [-] Wildcard response found: http://10.10.10.112/.htaccess/5ae6f6d2-7269-48e4-af73-b6d604f6d3b2 => 403 128 | 2019/01/17 05:49:34 [!] To force processing of Wildcard responses, specify the '-fw' switch. 129 | http://10.10.10.112/Images/ 130 | http://10.10.10.112/Images/.htaccess/ 131 | http://10.10.10.112/Images/.htpasswd/ 132 | http://10.10.10.112/Images/.hta/ 133 | 2019/01/17 05:49:35 [-] Wildcard response found: http://10.10.10.112/Images/.htpasswd/12ec114e-3818-499e-bcb2-9b4139e6eb71 => 403 134 | 2019/01/17 05:49:35 [!] To force processing of Wildcard responses, specify the '-fw' switch. 135 | 2019/01/17 05:49:35 [-] Wildcard response found: http://10.10.10.112/Images/.htaccess/003ce118-407c-483f-9edd-cb7cad646685 => 403 136 | 2019/01/17 05:49:35 [!] To force processing of Wildcard responses, specify the '-fw' switch. 137 | 2019/01/17 05:49:35 [-] Wildcard response found: http://10.10.10.112/Images/.hta/f0f57fb2-93f5-4c5c-9cc9-84c09090e8ad => 403 138 | 2019/01/17 05:49:35 [!] To force processing of Wildcard responses, specify the '-fw' switch. 139 | http://10.10.10.112/assets/ 140 | http://10.10.10.112/assets/.htaccess/ 141 | http://10.10.10.112/assets/.hta/ 142 | http://10.10.10.112/assets/.htpasswd/ 143 | 2019/01/17 05:49:36 [-] Wildcard response found: http://10.10.10.112/assets/.htaccess/6fe86826-d623-4119-a1e4-b54edac070b3 => 403 144 | 2019/01/17 05:49:36 [!] To force processing of Wildcard responses, specify the '-fw' switch. 145 | 2019/01/17 05:49:36 [-] Wildcard response found: http://10.10.10.112/assets/.hta/6259ad75-3d6a-4881-9a19-cad99623b204 => 403 146 | 2019/01/17 05:49:36 [!] To force processing of Wildcard responses, specify the '-fw' switch. 147 | 2019/01/17 05:49:36 [-] Wildcard response found: http://10.10.10.112/assets/.htpasswd/5d2a8a43-9fe7-4f37-8fee-1498ae9048da => 403 148 | 2019/01/17 05:49:36 [!] To force processing of Wildcard responses, specify the '-fw' switch. 149 | http://10.10.10.112/assets/css/ 150 | http://10.10.10.112/images/ 151 | http://10.10.10.112/assets/css/.htpasswd/ 152 | http://10.10.10.112/assets/css/.htaccess/ 153 | http://10.10.10.112/assets/css/.hta/ 154 | 2019/01/17 05:49:41 [-] Wildcard response found: http://10.10.10.112/assets/css/.htpasswd/dc99e161-2379-4f98-930d-90248641fe73 => 403 155 | 2019/01/17 05:49:41 [!] To force processing of Wildcard responses, specify the '-fw' switch. 156 | 2019/01/17 05:49:41 [-] Wildcard response found: http://10.10.10.112/assets/css/.htaccess/dd6b320c-a54b-4e29-9821-dd7b78b68c82 => 403 157 | 2019/01/17 05:49:41 [!] To force processing of Wildcard responses, specify the '-fw' switch. 158 | 2019/01/17 05:49:41 [-] Wildcard response found: http://10.10.10.112/assets/css/.hta/917be959-5ac9-4201-b44f-c89dc1c9bcb5 => 403 159 | 2019/01/17 05:49:41 [!] To force processing of Wildcard responses, specify the '-fw' switch. 160 | http://10.10.10.112/images/.htaccess/ 161 | http://10.10.10.112/images/.htpasswd/ 162 | http://10.10.10.112/images/.hta/ 163 | 2019/01/17 05:49:41 [-] Wildcard response found: http://10.10.10.112/images/.hta/dfb25bd1-26c3-4fcd-9e0e-605fa3354505 => 403 164 | 2019/01/17 05:49:41 [!] To force processing of Wildcard responses, specify the '-fw' switch. 165 | 2019/01/17 05:49:41 [-] Wildcard response found: http://10.10.10.112/images/.htpasswd/5ccb242b-0c03-4df1-9132-20fe8cc4b01b => 403 166 | 2019/01/17 05:49:41 [!] To force processing of Wildcard responses, specify the '-fw' switch. 167 | 2019/01/17 05:49:41 [-] Wildcard response found: http://10.10.10.112/images/.htaccess/f64e7160-f6b6-4c89-a76f-1a34b3f879a5 => 403 168 | 2019/01/17 05:49:41 [!] To force processing of Wildcard responses, specify the '-fw' switch. 169 | http://10.10.10.112/assets/fonts/ 170 | http://10.10.10.112/assets/fonts/.htaccess/ 171 | http://10.10.10.112/assets/fonts/.hta/ 172 | http://10.10.10.112/assets/fonts/.htpasswd/ 173 | 2019/01/17 05:49:43 [-] Wildcard response found: http://10.10.10.112/assets/fonts/.htaccess/c29f202e-6bc1-47b0-bcaf-b450dbec87fc => 403 174 | 2019/01/17 05:49:43 [!] To force processing of Wildcard responses, specify the '-fw' switch. 175 | 2019/01/17 05:49:43 [-] Wildcard response found: http://10.10.10.112/assets/fonts/.hta/d0df1e60-81bc-4516-bcae-ea03999acbdb => 403 176 | 2019/01/17 05:49:43 [!] To force processing of Wildcard responses, specify the '-fw' switch. 177 | 2019/01/17 05:49:43 [-] Wildcard response found: http://10.10.10.112/assets/fonts/.htpasswd/03f5de0a-396b-4dc7-9d08-7b46202e3641 => 403 178 | 2019/01/17 05:49:43 [!] To force processing of Wildcard responses, specify the '-fw' switch. 179 | http://10.10.10.112/assets/js/ 180 | http://10.10.10.112/assets/js/.hta/ 181 | http://10.10.10.112/assets/js/.htaccess/ 182 | http://10.10.10.112/assets/js/.htpasswd/ 183 | 2019/01/17 05:49:44 [-] Wildcard response found: http://10.10.10.112/assets/js/.htpasswd/5c192b5e-8ef2-4a98-a850-64ff3308f82e => 403 184 | 2019/01/17 05:49:44 [!] To force processing of Wildcard responses, specify the '-fw' switch. 185 | 2019/01/17 05:49:44 [-] Wildcard response found: http://10.10.10.112/assets/js/.hta/bf5b535a-ced5-4d4e-9de4-8f748473615c => 403 186 | 2019/01/17 05:49:44 [!] To force processing of Wildcard responses, specify the '-fw' switch. 187 | 2019/01/17 05:49:44 [-] Wildcard response found: http://10.10.10.112/assets/js/.htaccess/f1483aeb-8572-4186-8221-19bddcf57ef7 => 403 188 | 2019/01/17 05:49:44 [!] To force processing of Wildcard responses, specify the '-fw' switch. 189 | All scans complete. Cleaning up. 190 | 191 | real 0m37.033s 192 | user 0m0.162s 193 | sys 0m0.112s 194 | 195 | # ls -l recursive* 196 | -rw-r--r-- 1 root root 962 Jan 11 20:42 recursive_gobuster_http:__10.10.10.112.log 197 | 198 | ``` 199 | 200 | ### Scan with STDERR sent to /dev/null 201 | This option is included to suppress all the **Wildcard response** messages sent to STDERR. 202 | 203 | ``` 204 | /opt/recursive_gobuster.pyz -d http://10.10.10.112 205 | http://10.10.10.112/.hta/ 206 | http://10.10.10.112/.htpasswd/ 207 | http://10.10.10.112/.htaccess/ 208 | http://10.10.10.112/Images/ 209 | http://10.10.10.112/Images/.htaccess/ 210 | http://10.10.10.112/Images/.htpasswd/ 211 | http://10.10.10.112/Images/.hta/ 212 | http://10.10.10.112/assets/ 213 | http://10.10.10.112/assets/.htaccess/ 214 | http://10.10.10.112/assets/.htpasswd/ 215 | http://10.10.10.112/assets/.hta/ 216 | http://10.10.10.112/assets/css/ 217 | http://10.10.10.112/images/ 218 | http://10.10.10.112/assets/css/.htaccess/ 219 | http://10.10.10.112/assets/css/.hta/ 220 | http://10.10.10.112/assets/css/.htpasswd/ 221 | http://10.10.10.112/images/.htaccess/ 222 | http://10.10.10.112/images/.htpasswd/ 223 | http://10.10.10.112/images/.hta/ 224 | http://10.10.10.112/assets/fonts/ 225 | http://10.10.10.112/assets/fonts/.hta/ 226 | http://10.10.10.112/assets/fonts/.htaccess/ 227 | http://10.10.10.112/assets/fonts/.htpasswd/ 228 | http://10.10.10.112/assets/js/ 229 | http://10.10.10.112/assets/js/.htaccess/ 230 | http://10.10.10.112/assets/js/.hta/ 231 | http://10.10.10.112/assets/js/.htpasswd/ 232 | All scans complete. Cleaning up. 233 | 234 | real 0m37.141s 235 | user 0m0.182s 236 | sys 0m0.073s 237 | ``` 238 | 239 | ### Scan with extensions 240 | ``` 241 | /opt/recursive_gobuster.pyz -x php,html http://10.10.10.112 242 | http://10.10.10.112/.htaccess/ 243 | -------- 8< -------- 244 | ``` 245 | 246 | ### Scan with different wordlist 247 | 248 | ``` 249 | /opt/recursive_gobuster.pyz -w /usr/share/wordlists/dirbuster/directory-list-2.3-medium.txt http://10.10.10.112 250 | http://10.10.10.112/.htaccess/ 251 | -------- 8< -------- 252 | ``` 253 | -------------------------------------------------------------------------------- /build.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # install dependency 4 | python3 -m pip install pyinotify --target recursive-gobuster 5 | 6 | # remove metadata and cache; we're done with pip 7 | rm -rvf recursive-gobuster/pyinotify-* recursive-gobuster/__pycache__ 8 | 9 | # package it up into an executable zip 10 | python3 -m zipapp -p "/usr/bin/env python3" recursive-gobuster 11 | 12 | chmod 755 recursive-gobuster.pyz -------------------------------------------------------------------------------- /recursive-gobuster.pyz: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/epi052/recursive-gobuster/f3e3893f607f7ffb8ea7874549ee349c38b5a27d/recursive-gobuster.pyz -------------------------------------------------------------------------------- /recursive-gobuster/__main__.py: -------------------------------------------------------------------------------- 1 | """ 2 | Title: recursive-gobuster 3 | Date: 20190110 4 | Author: epi 5 | https://epi052.gitlab.io/notes-to-self/ 6 | Tested on: 7 | linux/x86_64 4.15.0-43-generic 8 | Python 3.6.6 9 | pyinotify 0.9.6 10 | """ 11 | import time 12 | import signal 13 | import shutil 14 | import argparse 15 | import tempfile 16 | import subprocess 17 | 18 | from pathlib import Path 19 | 20 | import pyinotify 21 | 22 | active_scans = list() 23 | completed_scans = list() 24 | 25 | 26 | class EventHandler(pyinotify.ProcessEvent): 27 | """ 28 | Handles notifications and takes actions through specific processing methods. 29 | For an EVENT_TYPE, a process_EVENT_TYPE function will execute. 30 | """ 31 | 32 | def __init__(self, *args, **kwargs): 33 | super().__init__(*args, **kwargs) 34 | self.user = kwargs.get("user") 35 | self.proxy = kwargs.get("proxy") 36 | self.status = kwargs.get("status") 37 | self.tmpdir = kwargs.get("tmpdir") 38 | self.devnull = kwargs.get("devnull") 39 | self.threads = kwargs.get("threads") 40 | self.version = kwargs.get("version") 41 | self.password = kwargs.get("password") 42 | self.wordlist = kwargs.get("wordlist") 43 | self.extensions = kwargs.get("extensions") 44 | self.target = self.original_target = kwargs.get("target") 45 | 46 | def _normalize_targetname(self, tgt: str) -> str: 47 | """ Returns a string representing a target URL that is compatible with linux filesystem naming conventions. 48 | 49 | Forward slashes (/) are not allowed in linux file names. This function simply replaces them with an underscore. 50 | 51 | Args: 52 | tgt: target url i.e. http://10.10.10.112/images/ 53 | 54 | Returns: 55 | normalized target url i.e. http:__10.10.10.112_images_ 56 | """ 57 | return tgt.replace("/", "_") 58 | 59 | def run_gobuster(self, target: str) -> None: 60 | """ Runs gobuster in a non-blocking subprocess. 61 | 62 | The function is pretty opinionated about options fed to gobuster. Removing the -e, or -n will likely break 63 | functionality of the script. The other options are either configurable via command line options, or can be 64 | manipulated without any adverse side-effects. 65 | 66 | Hard-coded options/args 67 | -q 68 | Don't print the banner and other noise 69 | -e 70 | Expanded mode, print full URLs 71 | -k 72 | Skip SSL certificate verification 73 | 74 | Args: 75 | target: target url i.e. http://10.10.10.112/images/ 76 | """ 77 | normalized_target = self._normalize_targetname(target) 78 | 79 | command = ["gobuster"] 80 | 81 | if self.version == 3: 82 | command.append("dir") 83 | 84 | if not self.status: 85 | # don't show status codes 86 | command.append("-n") 87 | 88 | command.extend( 89 | [ 90 | "-q", # don't print banner 91 | "-e", # print full URLs 92 | "-k", # skip SSL cert verification 93 | "-t", 94 | self.threads, 95 | "-u", 96 | target, 97 | "-w", 98 | self.wordlist, 99 | "-o", 100 | f"{self.tmpdir}/{normalized_target}", 101 | ] 102 | ) 103 | 104 | if self.extensions: 105 | command.append("-x") 106 | command.append(self.extensions) 107 | 108 | if self.user: 109 | # gobuster silently ignores the case where -P is set but -U is not; we'll follow suit. 110 | command.append("-U") 111 | command.append(self.user) 112 | if self.password is not None: 113 | # password set to anything (including empty string) 114 | command.append("-P") 115 | command.append(self.password) 116 | 117 | if self.proxy: 118 | command.append("-p") 119 | command.append(self.proxy) 120 | 121 | suppress = subprocess.DEVNULL if self.devnull else None 122 | 123 | try: 124 | subprocess.Popen(command, stderr=suppress) 125 | except FileNotFoundError as e: 126 | print(e) 127 | raise SystemExit 128 | 129 | active_scans.append(normalized_target) 130 | 131 | def process_IN_MODIFY(self, event: pyinotify.Event) -> None: 132 | """ Handles event produced when a file is modified. 133 | 134 | This function is designed to trigger when any of the watched gobuster output files are appended to. The 135 | output files are appened to each time a new file/folder is identified by gobuster. This function will 136 | pull out the new entry and start a new gobuster scan against it, if appropriate. 137 | 138 | Args: 139 | event: pyinotify.Event 140 | """ 141 | with open(event.pathname) as f: 142 | for line in f: 143 | line = line.strip() 144 | if self.status: 145 | # status codes are included, need to grab just the url for processing 146 | # https://assetinventory.bugcrowd.com/favicon.ico (Status: 200) 147 | line = line.split(maxsplit=1)[0] 148 | 149 | """ 150 | In response to https://github.com/epi052/recursive-gobuster/issues/2 151 | 152 | In the scans below, 00.php/ should not kick off another scan. The loop below aims to address the problem. 153 | 154 | gobuster -q -n -e -k -t 20 -u https://bluejeans.com/00/ -w /wordlists/seclists/Discovery/Web-Content/common.txt -o /tmp/rcrsv-gbstryv_fcneq/https:__bluejeans.com_00_ -x php 155 | gobuster -q -n -e -k -t 20 -u https://bluejeans.com/00.php/ -w /wordlists/seclists/Discovery/Web-Content/common.txt -o /tmp/rcrsv-gbstryv_fcneq/https:__bluejeans.com_00.php_ -x php 156 | """ 157 | for extension in self.extensions.split(","): 158 | if line.endswith(f".{extension}"): 159 | break 160 | else: 161 | # found a path -> https://somedomain/images, add a forward slash to scan in case of dir-ness 162 | tgt = f"{line}/" 163 | 164 | normalized_target = self._normalize_targetname(tgt) 165 | 166 | if ( 167 | normalized_target in active_scans or normalized_target in completed_scans 168 | ): # skip active/complete 169 | continue 170 | 171 | # found a directory that is not being actively scanned and has not already been scanned 172 | self.run_gobuster(target=tgt) 173 | 174 | def process_IN_CLOSE_WRITE(self, event: pyinotify.Event) -> None: 175 | """ Handles event produced when a file that was open for writing is closed. 176 | 177 | This function is designed to trigger when any of the watched gobuster output files are closed. This is 178 | indicative of scan completion. 179 | 180 | Args: 181 | event: pyinotify.Event 182 | """ 183 | normalized_target = self._normalize_targetname(event.name) 184 | 185 | # scan related to the target is complete; remove it from active and place it in complete 186 | active_scans.remove(normalized_target) 187 | completed_scans.append(normalized_target) 188 | 189 | if not active_scans: 190 | # likely, no more scans are running 191 | time.sleep(3) # attempt to avoid race condition 192 | if not active_scans: 193 | # check one last time 194 | print(f"All scans complete. Cleaning up.") 195 | self.cleanup(None, None) 196 | 197 | def cleanup(self, sig, frame) -> None: 198 | """ Simple function to write results seen so far and remove the temp directory. 199 | 200 | Can be called from either all scans completing, or receiving a SIGINT. When triggered from catching a SIGINT, 201 | the function is called with two arguments: the signal number and the current stack frame. When we call it 202 | ourselves, we don't care about those, so we can just call this manually with sig=None,frame=None 203 | 204 | Args: 205 | sig: signal number or None 206 | frame: current stack frame or None 207 | """ 208 | results = list() 209 | pathtmpdir = Path(self.tmpdir) 210 | 211 | if pathtmpdir.exists(): # ensure we got at least some results 212 | for file in pathtmpdir.iterdir(): 213 | with file.open() as f: 214 | results += f.readlines() 215 | 216 | results.sort() 217 | 218 | with open( 219 | f"recursive-gobuster_{self._normalize_targetname(self.original_target)}.log", "w" 220 | ) as f: 221 | f.write("".join(results)) 222 | 223 | shutil.rmtree(self.tmpdir) 224 | 225 | raise SystemExit(0) 226 | 227 | 228 | def get_gobuster_version() -> int: 229 | """ Return an int representing gobuster's version. 230 | 231 | There is no --version or similar for gobuster, so this function checks output of running gobuster without 232 | any options/arguments. Depending on the usage statement, we determine whether or not gobuster is version 233 | 3+ or not. 234 | 235 | Returns: 236 | int representing gobuster version; internal representation only. Not in sync with gobuster releases 237 | """ 238 | proc = subprocess.run(["gobuster"], stdout=subprocess.PIPE) 239 | 240 | # version 3+ with dns dir etc... 241 | return 3 if b"Usage:" in proc.stdout.splitlines()[0] else 2 242 | 243 | 244 | def main(args_ns: argparse.Namespace) -> None: 245 | tmpdir = tempfile.mkdtemp(prefix="rcrsv-gbstr") # directory for gobuster scan results 246 | 247 | # watch manager stores the watches and provides operations on watches 248 | wm = pyinotify.WatchManager() 249 | 250 | version = get_gobuster_version() 251 | 252 | handler = EventHandler( 253 | target=args_ns.target, 254 | tmpdir=tmpdir, 255 | wordlist=args_ns.wordlist, 256 | threads=args_ns.threads, 257 | extensions=args_ns.extensions, 258 | devnull=args.devnull, 259 | user=args_ns.user, 260 | password=args_ns.password, 261 | proxy=args_ns.proxy, 262 | version=version, 263 | status=args_ns.status, 264 | ) 265 | 266 | notifier = pyinotify.Notifier(wm, handler) 267 | 268 | # watch for file appends (found dir/file) and files closing (scan complete) 269 | mask = pyinotify.IN_MODIFY | pyinotify.IN_CLOSE_WRITE 270 | 271 | wm.add_watch(tmpdir, mask) 272 | 273 | handler.run_gobuster(args_ns.target) # kick off first scan against initial target 274 | 275 | signal.signal(signal.SIGINT, handler.cleanup) # register signal handler to handle SIGINT 276 | 277 | notifier.loop() 278 | 279 | 280 | if __name__ == "__main__": 281 | parser = argparse.ArgumentParser() 282 | 283 | parser.add_argument( 284 | "-t", "--threads", default="20", help="# of threads for each spawned gobuster (default: 20)" 285 | ) 286 | parser.add_argument( 287 | "-x", 288 | "--extensions", 289 | help="extensions passed to the -x option for spawned gobuster", 290 | default="", 291 | ) 292 | parser.add_argument( 293 | "-w", 294 | "--wordlist", 295 | default="/usr/share/seclists/Discovery/Web-Content/common.txt", 296 | help="wordlist for each spawned gobuster (default: /usr/share/seclists/Discovery/Web-Content/common.txt)", 297 | ) 298 | parser.add_argument( 299 | "-d", "--devnull", action="store_true", default=False, help="send stderr to devnull" 300 | ) 301 | parser.add_argument("-U", "--user", help="Username for Basic Auth (dir mode only)") 302 | parser.add_argument("-P", "--password", help="Password for Basic Auth (dir mode only)") 303 | parser.add_argument( 304 | "-p", "--proxy", help="Proxy to use for requests [http(s)://host:port] (dir mode only)" 305 | ) 306 | parser.add_argument( 307 | "-s", 308 | "--status", 309 | help="Include status code reporting (default: false)", 310 | action="store_true", 311 | default=False, 312 | ) 313 | parser.add_argument("target", help="target to scan") 314 | 315 | args = parser.parse_args() 316 | 317 | main(args) 318 | -------------------------------------------------------------------------------- /recursive-gobuster/pyinotify.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | 3 | # pyinotify.py - python interface to inotify 4 | # Copyright (c) 2005-2015 Sebastien Martini 5 | # 6 | # Permission is hereby granted, free of charge, to any person obtaining a copy 7 | # of this software and associated documentation files (the "Software"), to deal 8 | # in the Software without restriction, including without limitation the rights 9 | # to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 10 | # copies of the Software, and to permit persons to whom the Software is 11 | # furnished to do so, subject to the following conditions: 12 | # 13 | # The above copyright notice and this permission notice shall be included in 14 | # all copies or substantial portions of the Software. 15 | # 16 | # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 17 | # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 18 | # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 19 | # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 20 | # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 21 | # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 22 | # THE SOFTWARE. 23 | """ 24 | pyinotify 25 | 26 | @author: Sebastien Martini 27 | @license: MIT License 28 | @contact: seb@dbzteam.org 29 | """ 30 | 31 | class PyinotifyError(Exception): 32 | """Indicates exceptions raised by a Pyinotify class.""" 33 | pass 34 | 35 | 36 | class UnsupportedPythonVersionError(PyinotifyError): 37 | """ 38 | Raised on unsupported Python versions. 39 | """ 40 | def __init__(self, version): 41 | """ 42 | @param version: Current Python version 43 | @type version: string 44 | """ 45 | PyinotifyError.__init__(self, 46 | ('Python %s is unsupported, requires ' 47 | 'at least Python 3.0') % version) 48 | 49 | 50 | # Check Python version 51 | import sys 52 | if sys.version_info < (3, 0): 53 | raise UnsupportedPythonVersionError(sys.version) 54 | 55 | 56 | # Import directives 57 | import threading 58 | import os 59 | import select 60 | import struct 61 | import fcntl 62 | import errno 63 | import termios 64 | import array 65 | import logging 66 | import atexit 67 | from collections import deque 68 | from datetime import datetime, timedelta 69 | import time 70 | import re 71 | import asyncore 72 | import glob 73 | import locale 74 | import subprocess 75 | 76 | try: 77 | from functools import reduce 78 | except ImportError: 79 | pass # Will fail on Python 2.4 which has reduce() builtin anyway. 80 | 81 | try: 82 | import ctypes 83 | import ctypes.util 84 | except ImportError: 85 | ctypes = None 86 | 87 | try: 88 | import inotify_syscalls 89 | except ImportError: 90 | inotify_syscalls = None 91 | 92 | 93 | __author__ = "seb@dbzteam.org (Sebastien Martini)" 94 | 95 | __version__ = "0.9.6" 96 | 97 | 98 | # Compatibity mode: set to True to improve compatibility with 99 | # Pyinotify 0.7.1. Do not set this variable yourself, call the 100 | # function compatibility_mode() instead. 101 | COMPATIBILITY_MODE = False 102 | 103 | 104 | class InotifyBindingNotFoundError(PyinotifyError): 105 | """ 106 | Raised when no inotify support couldn't be found. 107 | """ 108 | def __init__(self): 109 | err = "Couldn't find any inotify binding" 110 | PyinotifyError.__init__(self, err) 111 | 112 | 113 | class INotifyWrapper: 114 | """ 115 | Abstract class wrapping access to inotify's functions. This is an 116 | internal class. 117 | """ 118 | @staticmethod 119 | def create(): 120 | """ 121 | Factory method instanciating and returning the right wrapper. 122 | """ 123 | # First, try to use ctypes. 124 | if ctypes: 125 | inotify = _CtypesLibcINotifyWrapper() 126 | if inotify.init(): 127 | return inotify 128 | # Second, see if C extension is compiled. 129 | if inotify_syscalls: 130 | inotify = _INotifySyscallsWrapper() 131 | if inotify.init(): 132 | return inotify 133 | 134 | def get_errno(self): 135 | """ 136 | Return None is no errno code is available. 137 | """ 138 | return self._get_errno() 139 | 140 | def str_errno(self): 141 | code = self.get_errno() 142 | if code is None: 143 | return 'Errno: no errno support' 144 | return 'Errno=%s (%s)' % (os.strerror(code), errno.errorcode[code]) 145 | 146 | def inotify_init(self): 147 | return self._inotify_init() 148 | 149 | def inotify_add_watch(self, fd, pathname, mask): 150 | # Unicode strings must be encoded to string prior to calling this 151 | # method. 152 | assert isinstance(pathname, str) 153 | return self._inotify_add_watch(fd, pathname, mask) 154 | 155 | def inotify_rm_watch(self, fd, wd): 156 | return self._inotify_rm_watch(fd, wd) 157 | 158 | 159 | class _INotifySyscallsWrapper(INotifyWrapper): 160 | def __init__(self): 161 | # Stores the last errno value. 162 | self._last_errno = None 163 | 164 | def init(self): 165 | assert inotify_syscalls 166 | return True 167 | 168 | def _get_errno(self): 169 | return self._last_errno 170 | 171 | def _inotify_init(self): 172 | try: 173 | fd = inotify_syscalls.inotify_init() 174 | except IOError as err: 175 | self._last_errno = err.errno 176 | return -1 177 | return fd 178 | 179 | def _inotify_add_watch(self, fd, pathname, mask): 180 | try: 181 | wd = inotify_syscalls.inotify_add_watch(fd, pathname, mask) 182 | except IOError as err: 183 | self._last_errno = err.errno 184 | return -1 185 | return wd 186 | 187 | def _inotify_rm_watch(self, fd, wd): 188 | try: 189 | ret = inotify_syscalls.inotify_rm_watch(fd, wd) 190 | except IOError as err: 191 | self._last_errno = err.errno 192 | return -1 193 | return ret 194 | 195 | 196 | class _CtypesLibcINotifyWrapper(INotifyWrapper): 197 | def __init__(self): 198 | self._libc = None 199 | self._get_errno_func = None 200 | 201 | def init(self): 202 | assert ctypes 203 | 204 | try_libc_name = 'c' 205 | if sys.platform.startswith('freebsd'): 206 | try_libc_name = 'inotify' 207 | 208 | libc_name = None 209 | try: 210 | libc_name = ctypes.util.find_library(try_libc_name) 211 | except (OSError, IOError): 212 | pass # Will attemp to load it with None anyway. 213 | 214 | self._libc = ctypes.CDLL(libc_name, use_errno=True) 215 | self._get_errno_func = ctypes.get_errno 216 | 217 | # Eventually check that libc has needed inotify bindings. 218 | if (not hasattr(self._libc, 'inotify_init') or 219 | not hasattr(self._libc, 'inotify_add_watch') or 220 | not hasattr(self._libc, 'inotify_rm_watch')): 221 | return False 222 | 223 | self._libc.inotify_init.argtypes = [] 224 | self._libc.inotify_init.restype = ctypes.c_int 225 | self._libc.inotify_add_watch.argtypes = [ctypes.c_int, ctypes.c_char_p, 226 | ctypes.c_uint32] 227 | self._libc.inotify_add_watch.restype = ctypes.c_int 228 | self._libc.inotify_rm_watch.argtypes = [ctypes.c_int, ctypes.c_int] 229 | self._libc.inotify_rm_watch.restype = ctypes.c_int 230 | return True 231 | 232 | def _get_errno(self): 233 | assert self._get_errno_func 234 | return self._get_errno_func() 235 | 236 | def _inotify_init(self): 237 | assert self._libc is not None 238 | return self._libc.inotify_init() 239 | 240 | def _inotify_add_watch(self, fd, pathname, mask): 241 | assert self._libc is not None 242 | # Encodes path to a bytes string. This conversion seems required because 243 | # ctypes.create_string_buffer seems to manipulate bytes internally. 244 | # Moreover it seems that inotify_add_watch does not work very well when 245 | # it receives an ctypes.create_unicode_buffer instance as argument. 246 | pathname = pathname.encode(sys.getfilesystemencoding()) 247 | pathname = ctypes.create_string_buffer(pathname) 248 | return self._libc.inotify_add_watch(fd, pathname, mask) 249 | 250 | def _inotify_rm_watch(self, fd, wd): 251 | assert self._libc is not None 252 | return self._libc.inotify_rm_watch(fd, wd) 253 | 254 | 255 | # Logging 256 | def logger_init(): 257 | """Initialize logger instance.""" 258 | log = logging.getLogger("pyinotify") 259 | console_handler = logging.StreamHandler() 260 | console_handler.setFormatter( 261 | logging.Formatter("[%(asctime)s %(name)s %(levelname)s] %(message)s")) 262 | log.addHandler(console_handler) 263 | log.setLevel(20) 264 | return log 265 | 266 | log = logger_init() 267 | 268 | 269 | # inotify's variables 270 | class ProcINotify: 271 | """ 272 | Access (read, write) inotify's variables through /proc/sys/. Note that 273 | usually it requires administrator rights to update them. 274 | 275 | Examples: 276 | - Read max_queued_events attribute: myvar = max_queued_events.value 277 | - Update max_queued_events attribute: max_queued_events.value = 42 278 | """ 279 | def __init__(self, attr): 280 | self._base = "/proc/sys/fs/inotify" 281 | self._attr = attr 282 | 283 | def get_val(self): 284 | """ 285 | Gets attribute's value. 286 | 287 | @return: stored value. 288 | @rtype: int 289 | @raise IOError: if corresponding file in /proc/sys cannot be read. 290 | """ 291 | with open(os.path.join(self._base, self._attr), 'r') as file_obj: 292 | return int(file_obj.readline()) 293 | 294 | def set_val(self, nval): 295 | """ 296 | Sets new attribute's value. 297 | 298 | @param nval: replaces current value by nval. 299 | @type nval: int 300 | @raise IOError: if corresponding file in /proc/sys cannot be written. 301 | """ 302 | with open(os.path.join(self._base, self._attr), 'w') as file_obj: 303 | file_obj.write(str(nval) + '\n') 304 | 305 | value = property(get_val, set_val) 306 | 307 | def __repr__(self): 308 | return '<%s=%d>' % (self._attr, self.get_val()) 309 | 310 | 311 | # Inotify's variables 312 | # 313 | # Note: may raise IOError if the corresponding value in /proc/sys 314 | # cannot be accessed. 315 | # 316 | # Examples: 317 | # - read: myvar = max_queued_events.value 318 | # - update: max_queued_events.value = 42 319 | # 320 | for attrname in ('max_queued_events', 'max_user_instances', 'max_user_watches'): 321 | globals()[attrname] = ProcINotify(attrname) 322 | 323 | 324 | class EventsCodes: 325 | """ 326 | Set of codes corresponding to each kind of events. 327 | Some of these flags are used to communicate with inotify, whereas 328 | the others are sent to userspace by inotify notifying some events. 329 | 330 | @cvar IN_ACCESS: File was accessed. 331 | @type IN_ACCESS: int 332 | @cvar IN_MODIFY: File was modified. 333 | @type IN_MODIFY: int 334 | @cvar IN_ATTRIB: Metadata changed. 335 | @type IN_ATTRIB: int 336 | @cvar IN_CLOSE_WRITE: Writtable file was closed. 337 | @type IN_CLOSE_WRITE: int 338 | @cvar IN_CLOSE_NOWRITE: Unwrittable file closed. 339 | @type IN_CLOSE_NOWRITE: int 340 | @cvar IN_OPEN: File was opened. 341 | @type IN_OPEN: int 342 | @cvar IN_MOVED_FROM: File was moved from X. 343 | @type IN_MOVED_FROM: int 344 | @cvar IN_MOVED_TO: File was moved to Y. 345 | @type IN_MOVED_TO: int 346 | @cvar IN_CREATE: Subfile was created. 347 | @type IN_CREATE: int 348 | @cvar IN_DELETE: Subfile was deleted. 349 | @type IN_DELETE: int 350 | @cvar IN_DELETE_SELF: Self (watched item itself) was deleted. 351 | @type IN_DELETE_SELF: int 352 | @cvar IN_MOVE_SELF: Self (watched item itself) was moved. 353 | @type IN_MOVE_SELF: int 354 | @cvar IN_UNMOUNT: Backing fs was unmounted. 355 | @type IN_UNMOUNT: int 356 | @cvar IN_Q_OVERFLOW: Event queued overflowed. 357 | @type IN_Q_OVERFLOW: int 358 | @cvar IN_IGNORED: File was ignored. 359 | @type IN_IGNORED: int 360 | @cvar IN_ONLYDIR: only watch the path if it is a directory (new 361 | in kernel 2.6.15). 362 | @type IN_ONLYDIR: int 363 | @cvar IN_DONT_FOLLOW: don't follow a symlink (new in kernel 2.6.15). 364 | IN_ONLYDIR we can make sure that we don't watch 365 | the target of symlinks. 366 | @type IN_DONT_FOLLOW: int 367 | @cvar IN_EXCL_UNLINK: Events are not generated for children after they 368 | have been unlinked from the watched directory. 369 | (new in kernel 2.6.36). 370 | @type IN_EXCL_UNLINK: int 371 | @cvar IN_MASK_ADD: add to the mask of an already existing watch (new 372 | in kernel 2.6.14). 373 | @type IN_MASK_ADD: int 374 | @cvar IN_ISDIR: Event occurred against dir. 375 | @type IN_ISDIR: int 376 | @cvar IN_ONESHOT: Only send event once. 377 | @type IN_ONESHOT: int 378 | @cvar ALL_EVENTS: Alias for considering all of the events. 379 | @type ALL_EVENTS: int 380 | """ 381 | 382 | # The idea here is 'configuration-as-code' - this way, we get our nice class 383 | # constants, but we also get nice human-friendly text mappings to do lookups 384 | # against as well, for free: 385 | FLAG_COLLECTIONS = {'OP_FLAGS': { 386 | 'IN_ACCESS' : 0x00000001, # File was accessed 387 | 'IN_MODIFY' : 0x00000002, # File was modified 388 | 'IN_ATTRIB' : 0x00000004, # Metadata changed 389 | 'IN_CLOSE_WRITE' : 0x00000008, # Writable file was closed 390 | 'IN_CLOSE_NOWRITE' : 0x00000010, # Unwritable file closed 391 | 'IN_OPEN' : 0x00000020, # File was opened 392 | 'IN_MOVED_FROM' : 0x00000040, # File was moved from X 393 | 'IN_MOVED_TO' : 0x00000080, # File was moved to Y 394 | 'IN_CREATE' : 0x00000100, # Subfile was created 395 | 'IN_DELETE' : 0x00000200, # Subfile was deleted 396 | 'IN_DELETE_SELF' : 0x00000400, # Self (watched item itself) 397 | # was deleted 398 | 'IN_MOVE_SELF' : 0x00000800, # Self (watched item itself) was moved 399 | }, 400 | 'EVENT_FLAGS': { 401 | 'IN_UNMOUNT' : 0x00002000, # Backing fs was unmounted 402 | 'IN_Q_OVERFLOW' : 0x00004000, # Event queued overflowed 403 | 'IN_IGNORED' : 0x00008000, # File was ignored 404 | }, 405 | 'SPECIAL_FLAGS': { 406 | 'IN_ONLYDIR' : 0x01000000, # only watch the path if it is a 407 | # directory 408 | 'IN_DONT_FOLLOW' : 0x02000000, # don't follow a symlink 409 | 'IN_EXCL_UNLINK' : 0x04000000, # exclude events on unlinked objects 410 | 'IN_MASK_ADD' : 0x20000000, # add to the mask of an already 411 | # existing watch 412 | 'IN_ISDIR' : 0x40000000, # event occurred against dir 413 | 'IN_ONESHOT' : 0x80000000, # only send event once 414 | }, 415 | } 416 | 417 | def maskname(mask): 418 | """ 419 | Returns the event name associated to mask. IN_ISDIR is appended to 420 | the result when appropriate. Note: only one event is returned, because 421 | only one event can be raised at a given time. 422 | 423 | @param mask: mask. 424 | @type mask: int 425 | @return: event name. 426 | @rtype: str 427 | """ 428 | ms = mask 429 | name = '%s' 430 | if mask & IN_ISDIR: 431 | ms = mask - IN_ISDIR 432 | name = '%s|IN_ISDIR' 433 | return name % EventsCodes.ALL_VALUES[ms] 434 | 435 | maskname = staticmethod(maskname) 436 | 437 | 438 | # So let's now turn the configuration into code 439 | EventsCodes.ALL_FLAGS = {} 440 | EventsCodes.ALL_VALUES = {} 441 | for flagc, valc in EventsCodes.FLAG_COLLECTIONS.items(): 442 | # Make the collections' members directly accessible through the 443 | # class dictionary 444 | setattr(EventsCodes, flagc, valc) 445 | 446 | # Collect all the flags under a common umbrella 447 | EventsCodes.ALL_FLAGS.update(valc) 448 | 449 | # Make the individual masks accessible as 'constants' at globals() scope 450 | # and masknames accessible by values. 451 | for name, val in valc.items(): 452 | globals()[name] = val 453 | EventsCodes.ALL_VALUES[val] = name 454 | 455 | 456 | # all 'normal' events 457 | ALL_EVENTS = reduce(lambda x, y: x | y, EventsCodes.OP_FLAGS.values()) 458 | EventsCodes.ALL_FLAGS['ALL_EVENTS'] = ALL_EVENTS 459 | EventsCodes.ALL_VALUES[ALL_EVENTS] = 'ALL_EVENTS' 460 | 461 | 462 | class _Event: 463 | """ 464 | Event structure, represent events raised by the system. This 465 | is the base class and should be subclassed. 466 | 467 | """ 468 | def __init__(self, dict_): 469 | """ 470 | Attach attributes (contained in dict_) to self. 471 | 472 | @param dict_: Set of attributes. 473 | @type dict_: dictionary 474 | """ 475 | for tpl in dict_.items(): 476 | setattr(self, *tpl) 477 | 478 | def __repr__(self): 479 | """ 480 | @return: Generic event string representation. 481 | @rtype: str 482 | """ 483 | s = '' 484 | for attr, value in sorted(self.__dict__.items(), key=lambda x: x[0]): 485 | if attr.startswith('_'): 486 | continue 487 | if attr == 'mask': 488 | value = hex(getattr(self, attr)) 489 | elif isinstance(value, str) and not value: 490 | value = "''" 491 | s += ' %s%s%s' % (output_format.field_name(attr), 492 | output_format.punctuation('='), 493 | output_format.field_value(value)) 494 | 495 | s = '%s%s%s %s' % (output_format.punctuation('<'), 496 | output_format.class_name(self.__class__.__name__), 497 | s, 498 | output_format.punctuation('>')) 499 | return s 500 | 501 | def __str__(self): 502 | return repr(self) 503 | 504 | 505 | class _RawEvent(_Event): 506 | """ 507 | Raw event, it contains only the informations provided by the system. 508 | It doesn't infer anything. 509 | """ 510 | def __init__(self, wd, mask, cookie, name): 511 | """ 512 | @param wd: Watch Descriptor. 513 | @type wd: int 514 | @param mask: Bitmask of events. 515 | @type mask: int 516 | @param cookie: Cookie. 517 | @type cookie: int 518 | @param name: Basename of the file or directory against which the 519 | event was raised in case where the watched directory 520 | is the parent directory. None if the event was raised 521 | on the watched item itself. 522 | @type name: string or None 523 | """ 524 | # Use this variable to cache the result of str(self), this object 525 | # is immutable. 526 | self._str = None 527 | # name: remove trailing '\0' 528 | d = {'wd': wd, 529 | 'mask': mask, 530 | 'cookie': cookie, 531 | 'name': name.rstrip('\0')} 532 | _Event.__init__(self, d) 533 | log.debug(str(self)) 534 | 535 | def __str__(self): 536 | if self._str is None: 537 | self._str = _Event.__str__(self) 538 | return self._str 539 | 540 | 541 | class Event(_Event): 542 | """ 543 | This class contains all the useful informations about the observed 544 | event. However, the presence of each field is not guaranteed and 545 | depends on the type of event. In effect, some fields are irrelevant 546 | for some kind of event (for example 'cookie' is meaningless for 547 | IN_CREATE whereas it is mandatory for IN_MOVE_TO). 548 | 549 | The possible fields are: 550 | - wd (int): Watch Descriptor. 551 | - mask (int): Mask. 552 | - maskname (str): Readable event name. 553 | - path (str): path of the file or directory being watched. 554 | - name (str): Basename of the file or directory against which the 555 | event was raised in case where the watched directory 556 | is the parent directory. None if the event was raised 557 | on the watched item itself. This field is always provided 558 | even if the string is ''. 559 | - pathname (str): Concatenation of 'path' and 'name'. 560 | - src_pathname (str): Only present for IN_MOVED_TO events and only in 561 | the case where IN_MOVED_FROM events are watched too. Holds the 562 | source pathname from where pathname was moved from. 563 | - cookie (int): Cookie. 564 | - dir (bool): True if the event was raised against a directory. 565 | 566 | """ 567 | def __init__(self, raw): 568 | """ 569 | Concretely, this is the raw event plus inferred infos. 570 | """ 571 | _Event.__init__(self, raw) 572 | self.maskname = EventsCodes.maskname(self.mask) 573 | if COMPATIBILITY_MODE: 574 | self.event_name = self.maskname 575 | try: 576 | if self.name: 577 | self.pathname = os.path.abspath(os.path.join(self.path, 578 | self.name)) 579 | else: 580 | self.pathname = os.path.abspath(self.path) 581 | except AttributeError as err: 582 | # Usually it is not an error some events are perfectly valids 583 | # despite the lack of these attributes. 584 | log.debug(err) 585 | 586 | 587 | class ProcessEventError(PyinotifyError): 588 | """ 589 | ProcessEventError Exception. Raised on ProcessEvent error. 590 | """ 591 | def __init__(self, err): 592 | """ 593 | @param err: Exception error description. 594 | @type err: string 595 | """ 596 | PyinotifyError.__init__(self, err) 597 | 598 | 599 | class _ProcessEvent: 600 | """ 601 | Abstract processing event class. 602 | """ 603 | def __call__(self, event): 604 | """ 605 | To behave like a functor the object must be callable. 606 | This method is a dispatch method. Its lookup order is: 607 | 1. process_MASKNAME method 608 | 2. process_FAMILY_NAME method 609 | 3. otherwise calls process_default 610 | 611 | @param event: Event to be processed. 612 | @type event: Event object 613 | @return: By convention when used from the ProcessEvent class: 614 | - Returning False or None (default value) means keep on 615 | executing next chained functors (see chain.py example). 616 | - Returning True instead means do not execute next 617 | processing functions. 618 | @rtype: bool 619 | @raise ProcessEventError: Event object undispatchable, 620 | unknown event. 621 | """ 622 | stripped_mask = event.mask - (event.mask & IN_ISDIR) 623 | maskname = EventsCodes.ALL_VALUES.get(stripped_mask) 624 | if maskname is None: 625 | raise ProcessEventError("Unknown mask 0x%08x" % stripped_mask) 626 | 627 | # 1- look for process_MASKNAME 628 | meth = getattr(self, 'process_' + maskname, None) 629 | if meth is not None: 630 | return meth(event) 631 | # 2- look for process_FAMILY_NAME 632 | meth = getattr(self, 'process_IN_' + maskname.split('_')[1], None) 633 | if meth is not None: 634 | return meth(event) 635 | # 3- default call method process_default 636 | return self.process_default(event) 637 | 638 | def __repr__(self): 639 | return '<%s>' % self.__class__.__name__ 640 | 641 | 642 | class _SysProcessEvent(_ProcessEvent): 643 | """ 644 | There is three kind of processing according to each event: 645 | 646 | 1. special handling (deletion from internal container, bug, ...). 647 | 2. default treatment: which is applied to the majority of events. 648 | 3. IN_ISDIR is never sent alone, he is piggybacked with a standard 649 | event, he is not processed as the others events, instead, its 650 | value is captured and appropriately aggregated to dst event. 651 | """ 652 | def __init__(self, wm, notifier): 653 | """ 654 | 655 | @param wm: Watch Manager. 656 | @type wm: WatchManager instance 657 | @param notifier: Notifier. 658 | @type notifier: Notifier instance 659 | """ 660 | self._watch_manager = wm # watch manager 661 | self._notifier = notifier # notifier 662 | self._mv_cookie = {} # {cookie(int): (src_path(str), date), ...} 663 | self._mv = {} # {src_path(str): (dst_path(str), date), ...} 664 | 665 | def cleanup(self): 666 | """ 667 | Cleanup (delete) old (>1mn) records contained in self._mv_cookie 668 | and self._mv. 669 | """ 670 | date_cur_ = datetime.now() 671 | for seq in (self._mv_cookie, self._mv): 672 | for k in list(seq.keys()): 673 | if (date_cur_ - seq[k][1]) > timedelta(minutes=1): 674 | log.debug('Cleanup: deleting entry %s', seq[k][0]) 675 | del seq[k] 676 | 677 | def process_IN_CREATE(self, raw_event): 678 | """ 679 | If the event affects a directory and the auto_add flag of the 680 | targetted watch is set to True, a new watch is added on this 681 | new directory, with the same attribute values than those of 682 | this watch. 683 | """ 684 | if raw_event.mask & IN_ISDIR: 685 | watch_ = self._watch_manager.get_watch(raw_event.wd) 686 | created_dir = os.path.join(watch_.path, raw_event.name) 687 | if watch_.auto_add and not watch_.exclude_filter(created_dir): 688 | addw = self._watch_manager.add_watch 689 | # The newly monitored directory inherits attributes from its 690 | # parent directory. 691 | addw_ret = addw(created_dir, watch_.mask, 692 | proc_fun=watch_.proc_fun, 693 | rec=False, auto_add=watch_.auto_add, 694 | exclude_filter=watch_.exclude_filter) 695 | 696 | # Trick to handle mkdir -p /d1/d2/t3 where d1 is watched and 697 | # d2 and t3 (directory or file) are created. 698 | # Since the directory d2 is new, then everything inside it must 699 | # also be new. 700 | created_dir_wd = addw_ret.get(created_dir) 701 | if ((created_dir_wd is not None) and (created_dir_wd > 0) and 702 | os.path.isdir(created_dir)): 703 | try: 704 | for name in os.listdir(created_dir): 705 | inner = os.path.join(created_dir, name) 706 | if self._watch_manager.get_wd(inner) is not None: 707 | continue 708 | # Generate (simulate) creation events for sub- 709 | # directories and files. 710 | if os.path.isfile(inner): 711 | # symlinks are handled as files. 712 | flags = IN_CREATE 713 | elif os.path.isdir(inner): 714 | flags = IN_CREATE | IN_ISDIR 715 | else: 716 | # This path should not be taken. 717 | continue 718 | rawevent = _RawEvent(created_dir_wd, flags, 0, name) 719 | self._notifier.append_event(rawevent) 720 | except OSError as err: 721 | msg = "process_IN_CREATE, invalid directory: %s" 722 | log.debug(msg % str(err)) 723 | return self.process_default(raw_event) 724 | 725 | def process_IN_MOVED_FROM(self, raw_event): 726 | """ 727 | Map the cookie with the source path (+ date for cleaning). 728 | """ 729 | watch_ = self._watch_manager.get_watch(raw_event.wd) 730 | path_ = watch_.path 731 | src_path = os.path.normpath(os.path.join(path_, raw_event.name)) 732 | self._mv_cookie[raw_event.cookie] = (src_path, datetime.now()) 733 | return self.process_default(raw_event, {'cookie': raw_event.cookie}) 734 | 735 | def process_IN_MOVED_TO(self, raw_event): 736 | """ 737 | Map the source path with the destination path (+ date for 738 | cleaning). 739 | """ 740 | watch_ = self._watch_manager.get_watch(raw_event.wd) 741 | path_ = watch_.path 742 | dst_path = os.path.normpath(os.path.join(path_, raw_event.name)) 743 | mv_ = self._mv_cookie.get(raw_event.cookie) 744 | to_append = {'cookie': raw_event.cookie} 745 | if mv_ is not None: 746 | self._mv[mv_[0]] = (dst_path, datetime.now()) 747 | # Let's assume that IN_MOVED_FROM event is always queued before 748 | # that its associated (they share a common cookie) IN_MOVED_TO 749 | # event is queued itself. It is then possible in that scenario 750 | # to provide as additional information to the IN_MOVED_TO event 751 | # the original pathname of the moved file/directory. 752 | to_append['src_pathname'] = mv_[0] 753 | elif (raw_event.mask & IN_ISDIR and watch_.auto_add and 754 | not watch_.exclude_filter(dst_path)): 755 | # We got a diretory that's "moved in" from an unknown source and 756 | # auto_add is enabled. Manually add watches to the inner subtrees. 757 | # The newly monitored directory inherits attributes from its 758 | # parent directory. 759 | self._watch_manager.add_watch(dst_path, watch_.mask, 760 | proc_fun=watch_.proc_fun, 761 | rec=True, auto_add=True, 762 | exclude_filter=watch_.exclude_filter) 763 | return self.process_default(raw_event, to_append) 764 | 765 | def process_IN_MOVE_SELF(self, raw_event): 766 | """ 767 | STATUS: the following bug has been fixed in recent kernels (FIXME: 768 | which version ?). Now it raises IN_DELETE_SELF instead. 769 | 770 | Old kernels were bugged, this event raised when the watched item 771 | were moved, so we had to update its path, but under some circumstances 772 | it was impossible: if its parent directory and its destination 773 | directory wasn't watched. The kernel (see include/linux/fsnotify.h) 774 | doesn't bring us enough informations like the destination path of 775 | moved items. 776 | """ 777 | watch_ = self._watch_manager.get_watch(raw_event.wd) 778 | src_path = watch_.path 779 | mv_ = self._mv.get(src_path) 780 | if mv_: 781 | dest_path = mv_[0] 782 | watch_.path = dest_path 783 | # add the separator to the source path to avoid overlapping 784 | # path issue when testing with startswith() 785 | src_path += os.path.sep 786 | src_path_len = len(src_path) 787 | # The next loop renames all watches with src_path as base path. 788 | # It seems that IN_MOVE_SELF does not provide IN_ISDIR information 789 | # therefore the next loop is iterated even if raw_event is a file. 790 | for w in self._watch_manager.watches.values(): 791 | if w.path.startswith(src_path): 792 | # Note that dest_path is a normalized path. 793 | w.path = os.path.join(dest_path, w.path[src_path_len:]) 794 | else: 795 | log.error("The pathname '%s' of this watch %s has probably changed " 796 | "and couldn't be updated, so it cannot be trusted " 797 | "anymore. To fix this error move directories/files only " 798 | "between watched parents directories, in this case e.g. " 799 | "put a watch on '%s'.", 800 | watch_.path, watch_, 801 | os.path.normpath(os.path.join(watch_.path, 802 | os.path.pardir))) 803 | if not watch_.path.endswith('-unknown-path'): 804 | watch_.path += '-unknown-path' 805 | return self.process_default(raw_event) 806 | 807 | def process_IN_Q_OVERFLOW(self, raw_event): 808 | """ 809 | Only signal an overflow, most of the common flags are irrelevant 810 | for this event (path, wd, name). 811 | """ 812 | return Event({'mask': raw_event.mask}) 813 | 814 | def process_IN_IGNORED(self, raw_event): 815 | """ 816 | The watch descriptor raised by this event is now ignored (forever), 817 | it can be safely deleted from the watch manager dictionary. 818 | After this event we can be sure that neither the event queue nor 819 | the system will raise an event associated to this wd again. 820 | """ 821 | event_ = self.process_default(raw_event) 822 | self._watch_manager.del_watch(raw_event.wd) 823 | return event_ 824 | 825 | def process_default(self, raw_event, to_append=None): 826 | """ 827 | Commons handling for the followings events: 828 | 829 | IN_ACCESS, IN_MODIFY, IN_ATTRIB, IN_CLOSE_WRITE, IN_CLOSE_NOWRITE, 830 | IN_OPEN, IN_DELETE, IN_DELETE_SELF, IN_UNMOUNT. 831 | """ 832 | watch_ = self._watch_manager.get_watch(raw_event.wd) 833 | if raw_event.mask & (IN_DELETE_SELF | IN_MOVE_SELF): 834 | # Unfornulately this information is not provided by the kernel 835 | dir_ = watch_.dir 836 | else: 837 | dir_ = bool(raw_event.mask & IN_ISDIR) 838 | dict_ = {'wd': raw_event.wd, 839 | 'mask': raw_event.mask, 840 | 'path': watch_.path, 841 | 'name': raw_event.name, 842 | 'dir': dir_} 843 | if COMPATIBILITY_MODE: 844 | dict_['is_dir'] = dir_ 845 | if to_append is not None: 846 | dict_.update(to_append) 847 | return Event(dict_) 848 | 849 | 850 | class ProcessEvent(_ProcessEvent): 851 | """ 852 | Process events objects, can be specialized via subclassing, thus its 853 | behavior can be overriden: 854 | 855 | Note: you should not override __init__ in your subclass instead define 856 | a my_init() method, this method will be called automatically from the 857 | constructor of this class with its optionals parameters. 858 | 859 | 1. Provide specialized individual methods, e.g. process_IN_DELETE for 860 | processing a precise type of event (e.g. IN_DELETE in this case). 861 | 2. Or/and provide methods for processing events by 'family', e.g. 862 | process_IN_CLOSE method will process both IN_CLOSE_WRITE and 863 | IN_CLOSE_NOWRITE events (if process_IN_CLOSE_WRITE and 864 | process_IN_CLOSE_NOWRITE aren't defined though). 865 | 3. Or/and override process_default for catching and processing all 866 | the remaining types of events. 867 | """ 868 | pevent = None 869 | 870 | def __init__(self, pevent=None, **kargs): 871 | """ 872 | Enable chaining of ProcessEvent instances. 873 | 874 | @param pevent: Optional callable object, will be called on event 875 | processing (before self). 876 | @type pevent: callable 877 | @param kargs: This constructor is implemented as a template method 878 | delegating its optionals keyworded arguments to the 879 | method my_init(). 880 | @type kargs: dict 881 | """ 882 | self.pevent = pevent 883 | self.my_init(**kargs) 884 | 885 | def my_init(self, **kargs): 886 | """ 887 | This method is called from ProcessEvent.__init__(). This method is 888 | empty here and must be redefined to be useful. In effect, if you 889 | need to specifically initialize your subclass' instance then you 890 | just have to override this method in your subclass. Then all the 891 | keyworded arguments passed to ProcessEvent.__init__() will be 892 | transmitted as parameters to this method. Beware you MUST pass 893 | keyword arguments though. 894 | 895 | @param kargs: optional delegated arguments from __init__(). 896 | @type kargs: dict 897 | """ 898 | pass 899 | 900 | def __call__(self, event): 901 | stop_chaining = False 902 | if self.pevent is not None: 903 | # By default methods return None so we set as guideline 904 | # that methods asking for stop chaining must explicitely 905 | # return non None or non False values, otherwise the default 906 | # behavior will be to accept chain call to the corresponding 907 | # local method. 908 | stop_chaining = self.pevent(event) 909 | if not stop_chaining: 910 | return _ProcessEvent.__call__(self, event) 911 | 912 | def nested_pevent(self): 913 | return self.pevent 914 | 915 | def process_IN_Q_OVERFLOW(self, event): 916 | """ 917 | By default this method only reports warning messages, you can overredide 918 | it by subclassing ProcessEvent and implement your own 919 | process_IN_Q_OVERFLOW method. The actions you can take on receiving this 920 | event is either to update the variable max_queued_events in order to 921 | handle more simultaneous events or to modify your code in order to 922 | accomplish a better filtering diminishing the number of raised events. 923 | Because this method is defined, IN_Q_OVERFLOW will never get 924 | transmitted as arguments to process_default calls. 925 | 926 | @param event: IN_Q_OVERFLOW event. 927 | @type event: dict 928 | """ 929 | log.warning('Event queue overflowed.') 930 | 931 | def process_default(self, event): 932 | """ 933 | Default processing event method. By default does nothing. Subclass 934 | ProcessEvent and redefine this method in order to modify its behavior. 935 | 936 | @param event: Event to be processed. Can be of any type of events but 937 | IN_Q_OVERFLOW events (see method process_IN_Q_OVERFLOW). 938 | @type event: Event instance 939 | """ 940 | pass 941 | 942 | 943 | class PrintAllEvents(ProcessEvent): 944 | """ 945 | Dummy class used to print events strings representations. For instance this 946 | class is used from command line to print all received events to stdout. 947 | """ 948 | def my_init(self, out=None): 949 | """ 950 | @param out: Where events will be written. 951 | @type out: Object providing a valid file object interface. 952 | """ 953 | if out is None: 954 | out = sys.stdout 955 | self._out = out 956 | 957 | def process_default(self, event): 958 | """ 959 | Writes event string representation to file object provided to 960 | my_init(). 961 | 962 | @param event: Event to be processed. Can be of any type of events but 963 | IN_Q_OVERFLOW events (see method process_IN_Q_OVERFLOW). 964 | @type event: Event instance 965 | """ 966 | self._out.write(str(event)) 967 | self._out.write('\n') 968 | self._out.flush() 969 | 970 | 971 | class ChainIfTrue(ProcessEvent): 972 | """ 973 | Makes conditional chaining depending on the result of the nested 974 | processing instance. 975 | """ 976 | def my_init(self, func): 977 | """ 978 | Method automatically called from base class constructor. 979 | """ 980 | self._func = func 981 | 982 | def process_default(self, event): 983 | return not self._func(event) 984 | 985 | 986 | class Stats(ProcessEvent): 987 | """ 988 | Compute and display trivial statistics about processed events. 989 | """ 990 | def my_init(self): 991 | """ 992 | Method automatically called from base class constructor. 993 | """ 994 | self._start_time = time.time() 995 | self._stats = {} 996 | self._stats_lock = threading.Lock() 997 | 998 | def process_default(self, event): 999 | """ 1000 | Processes |event|. 1001 | """ 1002 | self._stats_lock.acquire() 1003 | try: 1004 | events = event.maskname.split('|') 1005 | for event_name in events: 1006 | count = self._stats.get(event_name, 0) 1007 | self._stats[event_name] = count + 1 1008 | finally: 1009 | self._stats_lock.release() 1010 | 1011 | def _stats_copy(self): 1012 | self._stats_lock.acquire() 1013 | try: 1014 | return self._stats.copy() 1015 | finally: 1016 | self._stats_lock.release() 1017 | 1018 | def __repr__(self): 1019 | stats = self._stats_copy() 1020 | 1021 | elapsed = int(time.time() - self._start_time) 1022 | elapsed_str = '' 1023 | if elapsed < 60: 1024 | elapsed_str = str(elapsed) + 'sec' 1025 | elif 60 <= elapsed < 3600: 1026 | elapsed_str = '%dmn%dsec' % (elapsed / 60, elapsed % 60) 1027 | elif 3600 <= elapsed < 86400: 1028 | elapsed_str = '%dh%dmn' % (elapsed / 3600, (elapsed % 3600) / 60) 1029 | elif elapsed >= 86400: 1030 | elapsed_str = '%dd%dh' % (elapsed / 86400, (elapsed % 86400) / 3600) 1031 | stats['ElapsedTime'] = elapsed_str 1032 | 1033 | l = [] 1034 | for ev, value in sorted(stats.items(), key=lambda x: x[0]): 1035 | l.append(' %s=%s' % (output_format.field_name(ev), 1036 | output_format.field_value(value))) 1037 | s = '<%s%s >' % (output_format.class_name(self.__class__.__name__), 1038 | ''.join(l)) 1039 | return s 1040 | 1041 | def dump(self, filename): 1042 | """ 1043 | Dumps statistics. 1044 | 1045 | @param filename: filename where stats will be dumped, filename is 1046 | created and must not exist prior to this call. 1047 | @type filename: string 1048 | """ 1049 | flags = os.O_WRONLY|os.O_CREAT|os.O_NOFOLLOW|os.O_EXCL 1050 | fd = os.open(filename, flags, 0o0600) 1051 | os.write(fd, bytes(self.__str__(), locale.getpreferredencoding())) 1052 | os.close(fd) 1053 | 1054 | def __str__(self, scale=45): 1055 | stats = self._stats_copy() 1056 | if not stats: 1057 | return '' 1058 | 1059 | m = max(stats.values()) 1060 | unity = scale / m 1061 | fmt = '%%-26s%%-%ds%%s' % (len(output_format.field_value('@' * scale)) 1062 | + 1) 1063 | def func(x): 1064 | return fmt % (output_format.field_name(x[0]), 1065 | output_format.field_value('@' * int(x[1] * unity)), 1066 | output_format.simple('%d' % x[1], 'yellow')) 1067 | s = '\n'.join(map(func, sorted(stats.items(), key=lambda x: x[0]))) 1068 | return s 1069 | 1070 | 1071 | class NotifierError(PyinotifyError): 1072 | """ 1073 | Notifier Exception. Raised on Notifier error. 1074 | 1075 | """ 1076 | def __init__(self, err): 1077 | """ 1078 | @param err: Exception string's description. 1079 | @type err: string 1080 | """ 1081 | PyinotifyError.__init__(self, err) 1082 | 1083 | 1084 | class Notifier: 1085 | """ 1086 | Read notifications, process events. 1087 | 1088 | """ 1089 | def __init__(self, watch_manager, default_proc_fun=None, read_freq=0, 1090 | threshold=0, timeout=None): 1091 | """ 1092 | Initialization. read_freq, threshold and timeout parameters are used 1093 | when looping. 1094 | 1095 | @param watch_manager: Watch Manager. 1096 | @type watch_manager: WatchManager instance 1097 | @param default_proc_fun: Default processing method. If None, a new 1098 | instance of PrintAllEvents will be assigned. 1099 | @type default_proc_fun: instance of ProcessEvent 1100 | @param read_freq: if read_freq == 0, events are read asap, 1101 | if read_freq is > 0, this thread sleeps 1102 | max(0, read_freq - (timeout / 1000)) seconds. But if 1103 | timeout is None it may be different because 1104 | poll is blocking waiting for something to read. 1105 | @type read_freq: int 1106 | @param threshold: File descriptor will be read only if the accumulated 1107 | size to read becomes >= threshold. If != 0, you likely 1108 | want to use it in combination with an appropriate 1109 | value for read_freq because without that you would 1110 | keep looping without really reading anything and that 1111 | until the amount of events to read is >= threshold. 1112 | At least with read_freq set you might sleep. 1113 | @type threshold: int 1114 | @param timeout: see read_freq above. If provided, it must be set in 1115 | milliseconds. See 1116 | https://docs.python.org/3/library/select.html#select.poll.poll 1117 | @type timeout: int 1118 | """ 1119 | # Watch Manager instance 1120 | self._watch_manager = watch_manager 1121 | # File descriptor 1122 | self._fd = self._watch_manager.get_fd() 1123 | # Poll object and registration 1124 | self._pollobj = select.poll() 1125 | self._pollobj.register(self._fd, select.POLLIN) 1126 | # This pipe is correctely initialized and used by ThreadedNotifier 1127 | self._pipe = (-1, -1) 1128 | # Event queue 1129 | self._eventq = deque() 1130 | # System processing functor, common to all events 1131 | self._sys_proc_fun = _SysProcessEvent(self._watch_manager, self) 1132 | # Default processing method 1133 | self._default_proc_fun = default_proc_fun 1134 | if default_proc_fun is None: 1135 | self._default_proc_fun = PrintAllEvents() 1136 | # Loop parameters 1137 | self._read_freq = read_freq 1138 | self._threshold = threshold 1139 | self._timeout = timeout 1140 | # Coalesce events option 1141 | self._coalesce = False 1142 | # set of str(raw_event), only used when coalesce option is True 1143 | self._eventset = set() 1144 | 1145 | def append_event(self, event): 1146 | """ 1147 | Append a raw event to the event queue. 1148 | 1149 | @param event: An event. 1150 | @type event: _RawEvent instance. 1151 | """ 1152 | self._eventq.append(event) 1153 | 1154 | def proc_fun(self): 1155 | return self._default_proc_fun 1156 | 1157 | def coalesce_events(self, coalesce=True): 1158 | """ 1159 | Coalescing events. Events are usually processed by batchs, their size 1160 | depend on various factors. Thus, before processing them, events received 1161 | from inotify are aggregated in a fifo queue. If this coalescing 1162 | option is enabled events are filtered based on their unicity, only 1163 | unique events are enqueued, doublons are discarded. An event is unique 1164 | when the combination of its fields (wd, mask, cookie, name) is unique 1165 | among events of a same batch. After a batch of events is processed any 1166 | events is accepted again. By default this option is disabled, you have 1167 | to explictly call this function to turn it on. 1168 | 1169 | @param coalesce: Optional new coalescing value. True by default. 1170 | @type coalesce: Bool 1171 | """ 1172 | self._coalesce = coalesce 1173 | if not coalesce: 1174 | self._eventset.clear() 1175 | 1176 | def check_events(self, timeout=None): 1177 | """ 1178 | Check for new events available to read, blocks up to timeout 1179 | milliseconds. 1180 | 1181 | @param timeout: If specified it overrides the corresponding instance 1182 | attribute _timeout. timeout must be sepcified in 1183 | milliseconds. 1184 | @type timeout: int 1185 | 1186 | @return: New events to read. 1187 | @rtype: bool 1188 | """ 1189 | while True: 1190 | try: 1191 | # blocks up to 'timeout' milliseconds 1192 | if timeout is None: 1193 | timeout = self._timeout 1194 | ret = self._pollobj.poll(timeout) 1195 | except select.error as err: 1196 | if err.args[0] == errno.EINTR: 1197 | continue # interrupted, retry 1198 | else: 1199 | raise 1200 | else: 1201 | break 1202 | 1203 | if not ret or (self._pipe[0] == ret[0][0]): 1204 | return False 1205 | # only one fd is polled 1206 | return ret[0][1] & select.POLLIN 1207 | 1208 | def read_events(self): 1209 | """ 1210 | Read events from device, build _RawEvents, and enqueue them. 1211 | """ 1212 | buf_ = array.array('i', [0]) 1213 | # get event queue size 1214 | if fcntl.ioctl(self._fd, termios.FIONREAD, buf_, 1) == -1: 1215 | return 1216 | queue_size = buf_[0] 1217 | if queue_size < self._threshold: 1218 | log.debug('(fd: %d) %d bytes available to read but threshold is ' 1219 | 'fixed to %d bytes', self._fd, queue_size, 1220 | self._threshold) 1221 | return 1222 | 1223 | try: 1224 | # Read content from file 1225 | r = os.read(self._fd, queue_size) 1226 | except Exception as msg: 1227 | raise NotifierError(msg) 1228 | log.debug('Event queue size: %d', queue_size) 1229 | rsum = 0 # counter 1230 | while rsum < queue_size: 1231 | s_size = 16 1232 | # Retrieve wd, mask, cookie and fname_len 1233 | wd, mask, cookie, fname_len = struct.unpack('iIII', 1234 | r[rsum:rsum+s_size]) 1235 | # Retrieve name 1236 | bname, = struct.unpack('%ds' % fname_len, 1237 | r[rsum + s_size:rsum + s_size + fname_len]) 1238 | # FIXME: should we explictly call sys.getdefaultencoding() here ?? 1239 | uname = bname.decode() 1240 | rawevent = _RawEvent(wd, mask, cookie, uname) 1241 | if self._coalesce: 1242 | # Only enqueue new (unique) events. 1243 | raweventstr = str(rawevent) 1244 | if raweventstr not in self._eventset: 1245 | self._eventset.add(raweventstr) 1246 | self._eventq.append(rawevent) 1247 | else: 1248 | self._eventq.append(rawevent) 1249 | rsum += s_size + fname_len 1250 | 1251 | def process_events(self): 1252 | """ 1253 | Routine for processing events from queue by calling their 1254 | associated proccessing method (an instance of ProcessEvent). 1255 | It also does internal processings, to keep the system updated. 1256 | """ 1257 | while self._eventq: 1258 | raw_event = self._eventq.popleft() # pop next event 1259 | if self._watch_manager.ignore_events: 1260 | log.debug("Event ignored: %s" % repr(raw_event)) 1261 | continue 1262 | watch_ = self._watch_manager.get_watch(raw_event.wd) 1263 | if (watch_ is None) and not (raw_event.mask & IN_Q_OVERFLOW): 1264 | if not (raw_event.mask & IN_IGNORED): 1265 | # Not really sure how we ended up here, nor how we should 1266 | # handle these types of events and if it is appropriate to 1267 | # completly skip them (like we are doing here). 1268 | log.warning("Unable to retrieve Watch object associated to %s", 1269 | repr(raw_event)) 1270 | continue 1271 | revent = self._sys_proc_fun(raw_event) # system processings 1272 | if watch_ and watch_.proc_fun: 1273 | watch_.proc_fun(revent) # user processings 1274 | else: 1275 | self._default_proc_fun(revent) 1276 | self._sys_proc_fun.cleanup() # remove olds MOVED_* events records 1277 | if self._coalesce: 1278 | self._eventset.clear() 1279 | 1280 | def __daemonize(self, pid_file=None, stdin=os.devnull, stdout=os.devnull, 1281 | stderr=os.devnull): 1282 | """ 1283 | pid_file: file where the pid will be written. If pid_file=None the pid 1284 | is written to /var/run/.pid, if 1285 | pid_file=False no pid_file is written. 1286 | stdin, stdout, stderr: files associated to common streams. 1287 | """ 1288 | if pid_file is None: 1289 | dirname = '/var/run/' 1290 | basename = os.path.basename(sys.argv[0]) or 'pyinotify' 1291 | pid_file = os.path.join(dirname, basename + '.pid') 1292 | 1293 | if pid_file != False and os.path.lexists(pid_file): 1294 | err = 'Cannot daemonize: pid file %s already exists.' % pid_file 1295 | raise NotifierError(err) 1296 | 1297 | def fork_daemon(): 1298 | # Adapted from Chad J. Schroeder's recipe 1299 | # @see http://code.activestate.com/recipes/278731/ 1300 | pid = os.fork() 1301 | if (pid == 0): 1302 | # parent 2 1303 | os.setsid() 1304 | pid = os.fork() 1305 | if (pid == 0): 1306 | # child 1307 | os.chdir('/') 1308 | os.umask(0o022) 1309 | else: 1310 | # parent 2 1311 | os._exit(0) 1312 | else: 1313 | # parent 1 1314 | os._exit(0) 1315 | 1316 | fd_inp = os.open(stdin, os.O_RDONLY) 1317 | os.dup2(fd_inp, 0) 1318 | fd_out = os.open(stdout, os.O_WRONLY|os.O_CREAT, 0o0600) 1319 | os.dup2(fd_out, 1) 1320 | fd_err = os.open(stderr, os.O_WRONLY|os.O_CREAT, 0o0600) 1321 | os.dup2(fd_err, 2) 1322 | 1323 | # Detach task 1324 | fork_daemon() 1325 | 1326 | # Write pid 1327 | if pid_file != False: 1328 | flags = os.O_WRONLY|os.O_CREAT|os.O_NOFOLLOW|os.O_EXCL 1329 | fd_pid = os.open(pid_file, flags, 0o0600) 1330 | os.write(fd_pid, bytes(str(os.getpid()) + '\n', 1331 | locale.getpreferredencoding())) 1332 | os.close(fd_pid) 1333 | # Register unlink function 1334 | atexit.register(lambda : os.unlink(pid_file)) 1335 | 1336 | def _sleep(self, ref_time): 1337 | # Only consider sleeping if read_freq is > 0 1338 | if self._read_freq > 0: 1339 | cur_time = time.time() 1340 | sleep_amount = self._read_freq - (cur_time - ref_time) 1341 | if sleep_amount > 0: 1342 | log.debug('Now sleeping %d seconds', sleep_amount) 1343 | time.sleep(sleep_amount) 1344 | 1345 | def loop(self, callback=None, daemonize=False, **args): 1346 | """ 1347 | Events are read only one time every min(read_freq, timeout) 1348 | seconds at best and only if the size to read is >= threshold. 1349 | After this method returns it must not be called again for the same 1350 | instance. 1351 | 1352 | @param callback: Functor called after each event processing iteration. 1353 | Expects to receive the notifier object (self) as first 1354 | parameter. If this function returns True the loop is 1355 | immediately terminated otherwise the loop method keeps 1356 | looping. 1357 | @type callback: callable object or function 1358 | @param daemonize: This thread is daemonized if set to True. 1359 | @type daemonize: boolean 1360 | @param args: Optional and relevant only if daemonize is True. Remaining 1361 | keyworded arguments are directly passed to daemonize see 1362 | __daemonize() method. If pid_file=None or is set to a 1363 | pathname the caller must ensure the file does not exist 1364 | before this method is called otherwise an exception 1365 | pyinotify.NotifierError will be raised. If pid_file=False 1366 | it is still daemonized but the pid is not written in any 1367 | file. 1368 | @type args: various 1369 | """ 1370 | if daemonize: 1371 | self.__daemonize(**args) 1372 | 1373 | # Read and process events forever 1374 | while 1: 1375 | try: 1376 | self.process_events() 1377 | if (callback is not None) and (callback(self) is True): 1378 | break 1379 | ref_time = time.time() 1380 | # check_events is blocking 1381 | if self.check_events(): 1382 | self._sleep(ref_time) 1383 | self.read_events() 1384 | except KeyboardInterrupt: 1385 | # Stop monitoring if sigint is caught (Control-C). 1386 | log.debug('Pyinotify stops monitoring.') 1387 | break 1388 | # Close internals 1389 | self.stop() 1390 | 1391 | def stop(self): 1392 | """ 1393 | Close inotify's instance (close its file descriptor). 1394 | It destroys all existing watches, pending events,... 1395 | This method is automatically called at the end of loop(). 1396 | Afterward it is invalid to access this instance. 1397 | """ 1398 | if self._fd is not None: 1399 | self._pollobj.unregister(self._fd) 1400 | os.close(self._fd) 1401 | self._fd = None 1402 | self._sys_proc_fun = None 1403 | 1404 | 1405 | class ThreadedNotifier(threading.Thread, Notifier): 1406 | """ 1407 | This notifier inherits from threading.Thread for instanciating a separate 1408 | thread, and also inherits from Notifier, because it is a threaded notifier. 1409 | 1410 | Note that every functionality provided by this class is also provided 1411 | through Notifier class. Moreover Notifier should be considered first because 1412 | it is not threaded and could be easily daemonized. 1413 | """ 1414 | def __init__(self, watch_manager, default_proc_fun=None, read_freq=0, 1415 | threshold=0, timeout=None): 1416 | """ 1417 | Initialization, initialize base classes. read_freq, threshold and 1418 | timeout parameters are used when looping. 1419 | 1420 | @param watch_manager: Watch Manager. 1421 | @type watch_manager: WatchManager instance 1422 | @param default_proc_fun: Default processing method. See base class. 1423 | @type default_proc_fun: instance of ProcessEvent 1424 | @param read_freq: if read_freq == 0, events are read asap, 1425 | if read_freq is > 0, this thread sleeps 1426 | max(0, read_freq - (timeout / 1000)) seconds. 1427 | @type read_freq: int 1428 | @param threshold: File descriptor will be read only if the accumulated 1429 | size to read becomes >= threshold. If != 0, you likely 1430 | want to use it in combination with an appropriate 1431 | value set for read_freq because without that you would 1432 | keep looping without really reading anything and that 1433 | until the amount of events to read is >= threshold. At 1434 | least with read_freq you might sleep. 1435 | @type threshold: int 1436 | @param timeout: see read_freq above. If provided, it must be set in 1437 | milliseconds. See 1438 | https://docs.python.org/3/library/select.html#select.poll.poll 1439 | @type timeout: int 1440 | """ 1441 | # Init threading base class 1442 | threading.Thread.__init__(self) 1443 | # Stop condition 1444 | self._stop_event = threading.Event() 1445 | # Init Notifier base class 1446 | Notifier.__init__(self, watch_manager, default_proc_fun, read_freq, 1447 | threshold, timeout) 1448 | # Create a new pipe used for thread termination 1449 | self._pipe = os.pipe() 1450 | self._pollobj.register(self._pipe[0], select.POLLIN) 1451 | 1452 | def stop(self): 1453 | """ 1454 | Stop notifier's loop. Stop notification. Join the thread. 1455 | """ 1456 | self._stop_event.set() 1457 | os.write(self._pipe[1], b'stop') 1458 | threading.Thread.join(self) 1459 | Notifier.stop(self) 1460 | self._pollobj.unregister(self._pipe[0]) 1461 | os.close(self._pipe[0]) 1462 | os.close(self._pipe[1]) 1463 | 1464 | def loop(self): 1465 | """ 1466 | Thread's main loop. Don't meant to be called by user directly. 1467 | Call inherited start() method instead. 1468 | 1469 | Events are read only once time every min(read_freq, timeout) 1470 | seconds at best and only if the size of events to read is >= threshold. 1471 | """ 1472 | # When the loop must be terminated .stop() is called, 'stop' 1473 | # is written to pipe fd so poll() returns and .check_events() 1474 | # returns False which make evaluate the While's stop condition 1475 | # ._stop_event.isSet() wich put an end to the thread's execution. 1476 | while not self._stop_event.isSet(): 1477 | self.process_events() 1478 | ref_time = time.time() 1479 | if self.check_events(): 1480 | self._sleep(ref_time) 1481 | self.read_events() 1482 | 1483 | def run(self): 1484 | """ 1485 | Start thread's loop: read and process events until the method 1486 | stop() is called. 1487 | Never call this method directly, instead call the start() method 1488 | inherited from threading.Thread, which then will call run() in 1489 | its turn. 1490 | """ 1491 | self.loop() 1492 | 1493 | 1494 | class AsyncNotifier(asyncore.file_dispatcher, Notifier): 1495 | """ 1496 | This notifier inherits from asyncore.file_dispatcher in order to be able to 1497 | use pyinotify along with the asyncore framework. 1498 | 1499 | """ 1500 | def __init__(self, watch_manager, default_proc_fun=None, read_freq=0, 1501 | threshold=0, timeout=None, channel_map=None): 1502 | """ 1503 | Initializes the async notifier. The only additional parameter is 1504 | 'channel_map' which is the optional asyncore private map. See 1505 | Notifier class for the meaning of the others parameters. 1506 | 1507 | """ 1508 | Notifier.__init__(self, watch_manager, default_proc_fun, read_freq, 1509 | threshold, timeout) 1510 | asyncore.file_dispatcher.__init__(self, self._fd, channel_map) 1511 | 1512 | def handle_read(self): 1513 | """ 1514 | When asyncore tells us we can read from the fd, we proceed processing 1515 | events. This method can be overridden for handling a notification 1516 | differently. 1517 | 1518 | """ 1519 | self.read_events() 1520 | self.process_events() 1521 | 1522 | 1523 | class TornadoAsyncNotifier(Notifier): 1524 | """ 1525 | Tornado ioloop adapter. 1526 | 1527 | """ 1528 | def __init__(self, watch_manager, ioloop, callback=None, 1529 | default_proc_fun=None, read_freq=0, threshold=0, timeout=None, 1530 | channel_map=None): 1531 | """ 1532 | Note that if later you must call ioloop.close() be sure to let the 1533 | default parameter to all_fds=False. 1534 | 1535 | See example tornado_notifier.py for an example using this notifier. 1536 | 1537 | @param ioloop: Tornado's IO loop. 1538 | @type ioloop: tornado.ioloop.IOLoop instance. 1539 | @param callback: Functor called at the end of each call to handle_read 1540 | (IOLoop's read handler). Expects to receive the 1541 | notifier object (self) as single parameter. 1542 | @type callback: callable object or function 1543 | """ 1544 | self.io_loop = ioloop 1545 | self.handle_read_callback = callback 1546 | Notifier.__init__(self, watch_manager, default_proc_fun, read_freq, 1547 | threshold, timeout) 1548 | ioloop.add_handler(self._fd, self.handle_read, ioloop.READ) 1549 | 1550 | def stop(self): 1551 | self.io_loop.remove_handler(self._fd) 1552 | Notifier.stop(self) 1553 | 1554 | def handle_read(self, *args, **kwargs): 1555 | """ 1556 | See comment in AsyncNotifier. 1557 | 1558 | """ 1559 | self.read_events() 1560 | self.process_events() 1561 | if self.handle_read_callback is not None: 1562 | self.handle_read_callback(self) 1563 | 1564 | 1565 | class AsyncioNotifier(Notifier): 1566 | """ 1567 | 1568 | asyncio/trollius event loop adapter. 1569 | 1570 | """ 1571 | def __init__(self, watch_manager, loop, callback=None, 1572 | default_proc_fun=None, read_freq=0, threshold=0, timeout=None): 1573 | """ 1574 | 1575 | See examples/asyncio_notifier.py for an example usage. 1576 | 1577 | @param loop: asyncio or trollius event loop instance. 1578 | @type loop: asyncio.BaseEventLoop or trollius.BaseEventLoop instance. 1579 | @param callback: Functor called at the end of each call to handle_read. 1580 | Expects to receive the notifier object (self) as 1581 | single parameter. 1582 | @type callback: callable object or function 1583 | 1584 | """ 1585 | self.loop = loop 1586 | self.handle_read_callback = callback 1587 | Notifier.__init__(self, watch_manager, default_proc_fun, read_freq, 1588 | threshold, timeout) 1589 | loop.add_reader(self._fd, self.handle_read) 1590 | 1591 | def stop(self): 1592 | self.loop.remove_reader(self._fd) 1593 | Notifier.stop(self) 1594 | 1595 | def handle_read(self, *args, **kwargs): 1596 | self.read_events() 1597 | self.process_events() 1598 | if self.handle_read_callback is not None: 1599 | self.handle_read_callback(self) 1600 | 1601 | 1602 | class Watch: 1603 | """ 1604 | Represent a watch, i.e. a file or directory being watched. 1605 | 1606 | """ 1607 | __slots__ = ('wd', 'path', 'mask', 'proc_fun', 'auto_add', 1608 | 'exclude_filter', 'dir') 1609 | 1610 | def __init__(self, wd, path, mask, proc_fun, auto_add, exclude_filter): 1611 | """ 1612 | Initializations. 1613 | 1614 | @param wd: Watch descriptor. 1615 | @type wd: int 1616 | @param path: Path of the file or directory being watched. 1617 | @type path: str 1618 | @param mask: Mask. 1619 | @type mask: int 1620 | @param proc_fun: Processing callable object. 1621 | @type proc_fun: 1622 | @param auto_add: Automatically add watches on new directories. 1623 | @type auto_add: bool 1624 | @param exclude_filter: Boolean function, used to exclude new 1625 | directories from being automatically watched. 1626 | See WatchManager.__init__ 1627 | @type exclude_filter: callable object 1628 | """ 1629 | self.wd = wd 1630 | self.path = path 1631 | self.mask = mask 1632 | self.proc_fun = proc_fun 1633 | self.auto_add = auto_add 1634 | self.exclude_filter = exclude_filter 1635 | self.dir = os.path.isdir(self.path) 1636 | 1637 | def __repr__(self): 1638 | """ 1639 | @return: String representation. 1640 | @rtype: str 1641 | """ 1642 | s = ' '.join(['%s%s%s' % (output_format.field_name(attr), 1643 | output_format.punctuation('='), 1644 | output_format.field_value(getattr(self, 1645 | attr))) \ 1646 | for attr in self.__slots__ if not attr.startswith('_')]) 1647 | 1648 | s = '%s%s %s %s' % (output_format.punctuation('<'), 1649 | output_format.class_name(self.__class__.__name__), 1650 | s, 1651 | output_format.punctuation('>')) 1652 | return s 1653 | 1654 | 1655 | class ExcludeFilter: 1656 | """ 1657 | ExcludeFilter is an exclusion filter. 1658 | """ 1659 | def __init__(self, arg_lst): 1660 | """ 1661 | Examples: 1662 | ef1 = ExcludeFilter(["/etc/rc.*", "/etc/hostname"]) 1663 | ef2 = ExcludeFilter("/my/path/exclude.lst") 1664 | Where exclude.lst contains: 1665 | /etc/rc.* 1666 | /etc/hostname 1667 | 1668 | Note: it is not possible to exclude a file if its encapsulating 1669 | directory is itself watched. See this issue for more details 1670 | https://github.com/seb-m/pyinotify/issues/31 1671 | 1672 | @param arg_lst: is either a list of patterns or a filename from which 1673 | patterns will be loaded. 1674 | @type arg_lst: list of str or str 1675 | """ 1676 | if isinstance(arg_lst, str): 1677 | lst = self._load_patterns_from_file(arg_lst) 1678 | elif isinstance(arg_lst, list): 1679 | lst = arg_lst 1680 | else: 1681 | raise TypeError 1682 | 1683 | self._lregex = [] 1684 | for regex in lst: 1685 | self._lregex.append(re.compile(regex, re.UNICODE)) 1686 | 1687 | def _load_patterns_from_file(self, filename): 1688 | lst = [] 1689 | with open(filename, 'r') as file_obj: 1690 | for line in file_obj.readlines(): 1691 | # Trim leading an trailing whitespaces 1692 | pattern = line.strip() 1693 | if not pattern or pattern.startswith('#'): 1694 | continue 1695 | lst.append(pattern) 1696 | return lst 1697 | 1698 | def _match(self, regex, path): 1699 | return regex.match(path) is not None 1700 | 1701 | def __call__(self, path): 1702 | """ 1703 | @param path: Path to match against provided regexps. 1704 | @type path: str 1705 | @return: Return True if path has been matched and should 1706 | be excluded, False otherwise. 1707 | @rtype: bool 1708 | """ 1709 | for regex in self._lregex: 1710 | if self._match(regex, path): 1711 | return True 1712 | return False 1713 | 1714 | 1715 | class WatchManagerError(Exception): 1716 | """ 1717 | WatchManager Exception. Raised on error encountered on watches 1718 | operations. 1719 | """ 1720 | def __init__(self, msg, wmd): 1721 | """ 1722 | @param msg: Exception string's description. 1723 | @type msg: string 1724 | @param wmd: This dictionary contains the wd assigned to paths of the 1725 | same call for which watches were successfully added. 1726 | @type wmd: dict 1727 | """ 1728 | self.wmd = wmd 1729 | Exception.__init__(self, msg) 1730 | 1731 | 1732 | class WatchManager: 1733 | """ 1734 | Provide operations for watching files and directories. Its internal 1735 | dictionary is used to reference watched items. When used inside 1736 | threaded code, one must instanciate as many WatchManager instances as 1737 | there are ThreadedNotifier instances. 1738 | 1739 | """ 1740 | def __init__(self, exclude_filter=lambda path: False): 1741 | """ 1742 | Initialization: init inotify, init watch manager dictionary. 1743 | Raise OSError if initialization fails, raise InotifyBindingNotFoundError 1744 | if no inotify binding was found (through ctypes or from direct access to 1745 | syscalls). 1746 | 1747 | @param exclude_filter: boolean function, returns True if current 1748 | path must be excluded from being watched. 1749 | Convenient for providing a common exclusion 1750 | filter for every call to add_watch. 1751 | @type exclude_filter: callable object 1752 | """ 1753 | self._ignore_events = False 1754 | self._exclude_filter = exclude_filter 1755 | self._wmd = {} # watch dict key: watch descriptor, value: watch 1756 | 1757 | self._inotify_wrapper = INotifyWrapper.create() 1758 | if self._inotify_wrapper is None: 1759 | raise InotifyBindingNotFoundError() 1760 | 1761 | self._fd = self._inotify_wrapper.inotify_init() # file descriptor 1762 | if self._fd < 0: 1763 | err = 'Cannot initialize new instance of inotify, %s' 1764 | raise OSError(err % self._inotify_wrapper.str_errno()) 1765 | 1766 | def close(self): 1767 | """ 1768 | Close inotify's file descriptor, this action will also automatically 1769 | remove (i.e. stop watching) all its associated watch descriptors. 1770 | After a call to this method the WatchManager's instance become useless 1771 | and cannot be reused, a new instance must then be instanciated. It 1772 | makes sense to call this method in few situations for instance if 1773 | several independant WatchManager must be instanciated or if all watches 1774 | must be removed and no other watches need to be added. 1775 | """ 1776 | os.close(self._fd) 1777 | 1778 | def get_fd(self): 1779 | """ 1780 | Return assigned inotify's file descriptor. 1781 | 1782 | @return: File descriptor. 1783 | @rtype: int 1784 | """ 1785 | return self._fd 1786 | 1787 | def get_watch(self, wd): 1788 | """ 1789 | Get watch from provided watch descriptor wd. 1790 | 1791 | @param wd: Watch descriptor. 1792 | @type wd: int 1793 | """ 1794 | return self._wmd.get(wd) 1795 | 1796 | def del_watch(self, wd): 1797 | """ 1798 | Remove watch entry associated to watch descriptor wd. 1799 | 1800 | @param wd: Watch descriptor. 1801 | @type wd: int 1802 | """ 1803 | try: 1804 | del self._wmd[wd] 1805 | except KeyError as err: 1806 | log.error('Cannot delete unknown watch descriptor %s' % str(err)) 1807 | 1808 | @property 1809 | def watches(self): 1810 | """ 1811 | Get a reference on the internal watch manager dictionary. 1812 | 1813 | @return: Internal watch manager dictionary. 1814 | @rtype: dict 1815 | """ 1816 | return self._wmd 1817 | 1818 | def __format_path(self, path): 1819 | """ 1820 | Format path to its internal (stored in watch manager) representation. 1821 | """ 1822 | # path must be a unicode string (str) and is just normalized. 1823 | return os.path.normpath(path) 1824 | 1825 | def __add_watch(self, path, mask, proc_fun, auto_add, exclude_filter): 1826 | """ 1827 | Add a watch on path, build a Watch object and insert it in the 1828 | watch manager dictionary. Return the wd value. 1829 | """ 1830 | path = self.__format_path(path) 1831 | if auto_add and not mask & IN_CREATE: 1832 | mask |= IN_CREATE 1833 | wd = self._inotify_wrapper.inotify_add_watch(self._fd, path, mask) 1834 | if wd < 0: 1835 | return wd 1836 | watch = Watch(wd=wd, path=path, mask=mask, proc_fun=proc_fun, 1837 | auto_add=auto_add, exclude_filter=exclude_filter) 1838 | # wd are _always_ indexed with their original unicode paths in wmd. 1839 | self._wmd[wd] = watch 1840 | log.debug('New %s', watch) 1841 | return wd 1842 | 1843 | def __glob(self, path, do_glob): 1844 | if do_glob: 1845 | return glob.iglob(path) 1846 | else: 1847 | return [path] 1848 | 1849 | def add_watch(self, path, mask, proc_fun=None, rec=False, 1850 | auto_add=False, do_glob=False, quiet=True, 1851 | exclude_filter=None): 1852 | """ 1853 | Add watch(s) on the provided |path|(s) with associated |mask| flag 1854 | value and optionally with a processing |proc_fun| function and 1855 | recursive flag |rec| set to True. 1856 | All |path| components _must_ be str (i.e. unicode) objects. 1857 | If |path| is already watched it is ignored, but if it is called with 1858 | option rec=True a watch is put on each one of its not-watched 1859 | subdirectory. 1860 | 1861 | @param path: Path to watch, the path can either be a file or a 1862 | directory. Also accepts a sequence (list) of paths. 1863 | @type path: string or list of strings 1864 | @param mask: Bitmask of events. 1865 | @type mask: int 1866 | @param proc_fun: Processing object. 1867 | @type proc_fun: function or ProcessEvent instance or instance of 1868 | one of its subclasses or callable object. 1869 | @param rec: Recursively add watches from path on all its 1870 | subdirectories, set to False by default (doesn't 1871 | follows symlinks in any case). 1872 | @type rec: bool 1873 | @param auto_add: Automatically add watches on newly created 1874 | directories in watched parent |path| directory. 1875 | If |auto_add| is True, IN_CREATE is ored with |mask| 1876 | when the watch is added. 1877 | @type auto_add: bool 1878 | @param do_glob: Do globbing on pathname (see standard globbing 1879 | module for more informations). 1880 | @type do_glob: bool 1881 | @param quiet: if False raises a WatchManagerError exception on 1882 | error. See example not_quiet.py. 1883 | @type quiet: bool 1884 | @param exclude_filter: predicate (boolean function), which returns 1885 | True if the current path must be excluded 1886 | from being watched. This argument has 1887 | precedence over exclude_filter passed to 1888 | the class' constructor. 1889 | @type exclude_filter: callable object 1890 | @return: dict of paths associated to watch descriptors. A wd value 1891 | is positive if the watch was added sucessfully, otherwise 1892 | the value is negative. If the path was invalid or was already 1893 | watched it is not included into this returned dictionary. 1894 | @rtype: dict of {str: int} 1895 | """ 1896 | ret_ = {} # return {path: wd, ...} 1897 | 1898 | if exclude_filter is None: 1899 | exclude_filter = self._exclude_filter 1900 | 1901 | # normalize args as list elements 1902 | for npath in self.__format_param(path): 1903 | # Require that path be a unicode string 1904 | if not isinstance(npath, str): 1905 | ret_[path] = -3 1906 | continue 1907 | 1908 | # unix pathname pattern expansion 1909 | for apath in self.__glob(npath, do_glob): 1910 | # recursively list subdirs according to rec param 1911 | for rpath in self.__walk_rec(apath, rec): 1912 | if not exclude_filter(rpath): 1913 | wd = ret_[rpath] = self.__add_watch(rpath, mask, 1914 | proc_fun, 1915 | auto_add, 1916 | exclude_filter) 1917 | if wd < 0: 1918 | err = ('add_watch: cannot watch %s WD=%d, %s' % \ 1919 | (rpath, wd, 1920 | self._inotify_wrapper.str_errno())) 1921 | if quiet: 1922 | log.error(err) 1923 | else: 1924 | raise WatchManagerError(err, ret_) 1925 | else: 1926 | # Let's say -2 means 'explicitely excluded 1927 | # from watching'. 1928 | ret_[rpath] = -2 1929 | return ret_ 1930 | 1931 | def __get_sub_rec(self, lpath): 1932 | """ 1933 | Get every wd from self._wmd if its path is under the path of 1934 | one (at least) of those in lpath. Doesn't follow symlinks. 1935 | 1936 | @param lpath: list of watch descriptor 1937 | @type lpath: list of int 1938 | @return: list of watch descriptor 1939 | @rtype: list of int 1940 | """ 1941 | for d in lpath: 1942 | root = self.get_path(d) 1943 | if root is not None: 1944 | # always keep root 1945 | yield d 1946 | else: 1947 | # if invalid 1948 | continue 1949 | 1950 | # nothing else to expect 1951 | if not os.path.isdir(root): 1952 | continue 1953 | 1954 | # normalization 1955 | root = os.path.normpath(root) 1956 | # recursion 1957 | lend = len(root) 1958 | for iwd in self._wmd.items(): 1959 | cur = iwd[1].path 1960 | pref = os.path.commonprefix([root, cur]) 1961 | if root == os.sep or (len(pref) == lend and \ 1962 | len(cur) > lend and \ 1963 | cur[lend] == os.sep): 1964 | yield iwd[1].wd 1965 | 1966 | def update_watch(self, wd, mask=None, proc_fun=None, rec=False, 1967 | auto_add=False, quiet=True): 1968 | """ 1969 | Update existing watch descriptors |wd|. The |mask| value, the 1970 | processing object |proc_fun|, the recursive param |rec| and the 1971 | |auto_add| and |quiet| flags can all be updated. 1972 | 1973 | @param wd: Watch Descriptor to update. Also accepts a list of 1974 | watch descriptors. 1975 | @type wd: int or list of int 1976 | @param mask: Optional new bitmask of events. 1977 | @type mask: int 1978 | @param proc_fun: Optional new processing function. 1979 | @type proc_fun: function or ProcessEvent instance or instance of 1980 | one of its subclasses or callable object. 1981 | @param rec: Optionally adds watches recursively on all 1982 | subdirectories contained into |wd| directory. 1983 | @type rec: bool 1984 | @param auto_add: Automatically adds watches on newly created 1985 | directories in the watch's path corresponding to |wd|. 1986 | If |auto_add| is True, IN_CREATE is ored with |mask| 1987 | when the watch is updated. 1988 | @type auto_add: bool 1989 | @param quiet: If False raises a WatchManagerError exception on 1990 | error. See example not_quiet.py 1991 | @type quiet: bool 1992 | @return: dict of watch descriptors associated to booleans values. 1993 | True if the corresponding wd has been successfully 1994 | updated, False otherwise. 1995 | @rtype: dict of {int: bool} 1996 | """ 1997 | lwd = self.__format_param(wd) 1998 | if rec: 1999 | lwd = self.__get_sub_rec(lwd) 2000 | 2001 | ret_ = {} # return {wd: bool, ...} 2002 | for awd in lwd: 2003 | apath = self.get_path(awd) 2004 | if not apath or awd < 0: 2005 | err = 'update_watch: invalid WD=%d' % awd 2006 | if quiet: 2007 | log.error(err) 2008 | continue 2009 | raise WatchManagerError(err, ret_) 2010 | 2011 | if mask: 2012 | wd_ = self._inotify_wrapper.inotify_add_watch(self._fd, apath, 2013 | mask) 2014 | if wd_ < 0: 2015 | ret_[awd] = False 2016 | err = ('update_watch: cannot update %s WD=%d, %s' % \ 2017 | (apath, wd_, self._inotify_wrapper.str_errno())) 2018 | if quiet: 2019 | log.error(err) 2020 | continue 2021 | raise WatchManagerError(err, ret_) 2022 | 2023 | assert(awd == wd_) 2024 | 2025 | if proc_fun or auto_add: 2026 | watch_ = self._wmd[awd] 2027 | 2028 | if proc_fun: 2029 | watch_.proc_fun = proc_fun 2030 | 2031 | if auto_add: 2032 | watch_.auto_add = auto_add 2033 | 2034 | ret_[awd] = True 2035 | log.debug('Updated watch - %s', self._wmd[awd]) 2036 | return ret_ 2037 | 2038 | def __format_param(self, param): 2039 | """ 2040 | @param param: Parameter. 2041 | @type param: string or int 2042 | @return: wrap param. 2043 | @rtype: list of type(param) 2044 | """ 2045 | if isinstance(param, list): 2046 | for p_ in param: 2047 | yield p_ 2048 | else: 2049 | yield param 2050 | 2051 | def get_wd(self, path): 2052 | """ 2053 | Returns the watch descriptor associated to path. This method 2054 | presents a prohibitive cost, always prefer to keep the WD 2055 | returned by add_watch(). If the path is unknown it returns None. 2056 | 2057 | @param path: Path. 2058 | @type path: str 2059 | @return: WD or None. 2060 | @rtype: int or None 2061 | """ 2062 | path = self.__format_path(path) 2063 | for iwd in self._wmd.items(): 2064 | if iwd[1].path == path: 2065 | return iwd[0] 2066 | 2067 | def get_path(self, wd): 2068 | """ 2069 | Returns the path associated to WD, if WD is unknown it returns None. 2070 | 2071 | @param wd: Watch descriptor. 2072 | @type wd: int 2073 | @return: Path or None. 2074 | @rtype: string or None 2075 | """ 2076 | watch_ = self._wmd.get(wd) 2077 | if watch_ is not None: 2078 | return watch_.path 2079 | 2080 | def __walk_rec(self, top, rec): 2081 | """ 2082 | Yields each subdirectories of top, doesn't follow symlinks. 2083 | If rec is false, only yield top. 2084 | 2085 | @param top: root directory. 2086 | @type top: string 2087 | @param rec: recursive flag. 2088 | @type rec: bool 2089 | @return: path of one subdirectory. 2090 | @rtype: string 2091 | """ 2092 | if not rec or os.path.islink(top) or not os.path.isdir(top): 2093 | yield top 2094 | else: 2095 | for root, dirs, files in os.walk(top): 2096 | yield root 2097 | 2098 | def rm_watch(self, wd, rec=False, quiet=True): 2099 | """ 2100 | Removes watch(s). 2101 | 2102 | @param wd: Watch Descriptor of the file or directory to unwatch. 2103 | Also accepts a list of WDs. 2104 | @type wd: int or list of int. 2105 | @param rec: Recursively removes watches on every already watched 2106 | subdirectories and subfiles. 2107 | @type rec: bool 2108 | @param quiet: If False raises a WatchManagerError exception on 2109 | error. See example not_quiet.py 2110 | @type quiet: bool 2111 | @return: dict of watch descriptors associated to booleans values. 2112 | True if the corresponding wd has been successfully 2113 | removed, False otherwise. 2114 | @rtype: dict of {int: bool} 2115 | """ 2116 | lwd = self.__format_param(wd) 2117 | if rec: 2118 | lwd = self.__get_sub_rec(lwd) 2119 | 2120 | ret_ = {} # return {wd: bool, ...} 2121 | for awd in lwd: 2122 | # remove watch 2123 | wd_ = self._inotify_wrapper.inotify_rm_watch(self._fd, awd) 2124 | if wd_ < 0: 2125 | ret_[awd] = False 2126 | err = ('rm_watch: cannot remove WD=%d, %s' % \ 2127 | (awd, self._inotify_wrapper.str_errno())) 2128 | if quiet: 2129 | log.error(err) 2130 | continue 2131 | raise WatchManagerError(err, ret_) 2132 | 2133 | # Remove watch from our dictionary 2134 | if awd in self._wmd: 2135 | del self._wmd[awd] 2136 | ret_[awd] = True 2137 | log.debug('Watch WD=%d (%s) removed', awd, self.get_path(awd)) 2138 | return ret_ 2139 | 2140 | 2141 | def watch_transient_file(self, filename, mask, proc_class): 2142 | """ 2143 | Watch a transient file, which will be created and deleted frequently 2144 | over time (e.g. pid file). 2145 | 2146 | @attention: Currently under the call to this function it is not 2147 | possible to correctly watch the events triggered into the same 2148 | base directory than the directory where is located this watched 2149 | transient file. For instance it would be wrong to make these 2150 | two successive calls: wm.watch_transient_file('/var/run/foo.pid', ...) 2151 | and wm.add_watch('/var/run/', ...) 2152 | 2153 | @param filename: Filename. 2154 | @type filename: string 2155 | @param mask: Bitmask of events, should contain IN_CREATE and IN_DELETE. 2156 | @type mask: int 2157 | @param proc_class: ProcessEvent (or of one of its subclass), beware of 2158 | accepting a ProcessEvent's instance as argument into 2159 | __init__, see transient_file.py example for more 2160 | details. 2161 | @type proc_class: ProcessEvent's instance or of one of its subclasses. 2162 | @return: Same as add_watch(). 2163 | @rtype: Same as add_watch(). 2164 | """ 2165 | dirname = os.path.dirname(filename) 2166 | if dirname == '': 2167 | return {} # Maintains coherence with add_watch() 2168 | basename = os.path.basename(filename) 2169 | # Assuming we are watching at least for IN_CREATE and IN_DELETE 2170 | mask |= IN_CREATE | IN_DELETE 2171 | 2172 | def cmp_name(event): 2173 | if getattr(event, 'name') is None: 2174 | return False 2175 | return basename == event.name 2176 | return self.add_watch(dirname, mask, 2177 | proc_fun=proc_class(ChainIfTrue(func=cmp_name)), 2178 | rec=False, 2179 | auto_add=False, do_glob=False, 2180 | exclude_filter=lambda path: False) 2181 | 2182 | def get_ignore_events(self): 2183 | return self._ignore_events 2184 | 2185 | def set_ignore_events(self, nval): 2186 | self._ignore_events = nval 2187 | 2188 | ignore_events = property(get_ignore_events, set_ignore_events, 2189 | "Make watch manager ignoring new events.") 2190 | 2191 | 2192 | class RawOutputFormat: 2193 | """ 2194 | Format string representations. 2195 | """ 2196 | def __init__(self, format=None): 2197 | self.format = format or {} 2198 | 2199 | def simple(self, s, attribute): 2200 | if not isinstance(s, str): 2201 | s = str(s) 2202 | return (self.format.get(attribute, '') + s + 2203 | self.format.get('normal', '')) 2204 | 2205 | def punctuation(self, s): 2206 | """Punctuation color.""" 2207 | return self.simple(s, 'normal') 2208 | 2209 | def field_value(self, s): 2210 | """Field value color.""" 2211 | return self.simple(s, 'purple') 2212 | 2213 | def field_name(self, s): 2214 | """Field name color.""" 2215 | return self.simple(s, 'blue') 2216 | 2217 | def class_name(self, s): 2218 | """Class name color.""" 2219 | return self.format.get('red', '') + self.simple(s, 'bold') 2220 | 2221 | output_format = RawOutputFormat() 2222 | 2223 | class ColoredOutputFormat(RawOutputFormat): 2224 | """ 2225 | Format colored string representations. 2226 | """ 2227 | def __init__(self): 2228 | f = {'normal': '\033[0m', 2229 | 'black': '\033[30m', 2230 | 'red': '\033[31m', 2231 | 'green': '\033[32m', 2232 | 'yellow': '\033[33m', 2233 | 'blue': '\033[34m', 2234 | 'purple': '\033[35m', 2235 | 'cyan': '\033[36m', 2236 | 'bold': '\033[1m', 2237 | 'uline': '\033[4m', 2238 | 'blink': '\033[5m', 2239 | 'invert': '\033[7m'} 2240 | RawOutputFormat.__init__(self, f) 2241 | 2242 | 2243 | def compatibility_mode(): 2244 | """ 2245 | Use this function to turn on the compatibility mode. The compatibility 2246 | mode is used to improve compatibility with Pyinotify 0.7.1 (or older) 2247 | programs. The compatibility mode provides additional variables 'is_dir', 2248 | 'event_name', 'EventsCodes.IN_*' and 'EventsCodes.ALL_EVENTS' as 2249 | Pyinotify 0.7.1 provided. Do not call this function from new programs!! 2250 | Especially if there are developped for Pyinotify >= 0.8.x. 2251 | """ 2252 | setattr(EventsCodes, 'ALL_EVENTS', ALL_EVENTS) 2253 | for evname in globals(): 2254 | if evname.startswith('IN_'): 2255 | setattr(EventsCodes, evname, globals()[evname]) 2256 | global COMPATIBILITY_MODE 2257 | COMPATIBILITY_MODE = True 2258 | 2259 | 2260 | def command_line(): 2261 | """ 2262 | By default the watched path is '/tmp' and all types of events are 2263 | monitored. Events monitoring serves forever, type c^c to stop it. 2264 | """ 2265 | from optparse import OptionParser 2266 | 2267 | usage = "usage: %prog [options] [path1] [path2] [pathn]" 2268 | 2269 | parser = OptionParser(usage=usage) 2270 | parser.add_option("-v", "--verbose", action="store_true", 2271 | dest="verbose", help="Verbose mode") 2272 | parser.add_option("-r", "--recursive", action="store_true", 2273 | dest="recursive", 2274 | help="Add watches recursively on paths") 2275 | parser.add_option("-a", "--auto_add", action="store_true", 2276 | dest="auto_add", 2277 | help="Automatically add watches on new directories") 2278 | parser.add_option("-g", "--glob", action="store_true", 2279 | dest="glob", 2280 | help="Treat paths as globs") 2281 | parser.add_option("-e", "--events-list", metavar="EVENT[,...]", 2282 | dest="events_list", 2283 | help=("A comma-separated list of events to watch for - " 2284 | "see the documentation for valid options (defaults" 2285 | " to everything)")) 2286 | parser.add_option("-s", "--stats", action="store_true", 2287 | dest="stats", 2288 | help="Display dummy statistics") 2289 | parser.add_option("-V", "--version", action="store_true", 2290 | dest="version", help="Pyinotify version") 2291 | parser.add_option("-f", "--raw-format", action="store_true", 2292 | dest="raw_format", 2293 | help="Disable enhanced output format.") 2294 | parser.add_option("-c", "--command", action="store", 2295 | dest="command", 2296 | help="Shell command to run upon event") 2297 | 2298 | (options, args) = parser.parse_args() 2299 | 2300 | if options.verbose: 2301 | log.setLevel(10) 2302 | 2303 | if options.version: 2304 | print(__version__) 2305 | 2306 | if not options.raw_format: 2307 | global output_format 2308 | output_format = ColoredOutputFormat() 2309 | 2310 | if len(args) < 1: 2311 | path = '/tmp' # default watched path 2312 | else: 2313 | path = args 2314 | 2315 | # watch manager instance 2316 | wm = WatchManager() 2317 | # notifier instance and init 2318 | if options.stats: 2319 | notifier = Notifier(wm, default_proc_fun=Stats(), read_freq=5) 2320 | else: 2321 | notifier = Notifier(wm, default_proc_fun=PrintAllEvents()) 2322 | 2323 | # What mask to apply 2324 | mask = 0 2325 | if options.events_list: 2326 | events_list = options.events_list.split(',') 2327 | for ev in events_list: 2328 | evcode = EventsCodes.ALL_FLAGS.get(ev, 0) 2329 | if evcode: 2330 | mask |= evcode 2331 | else: 2332 | parser.error("The event '%s' specified with option -e" 2333 | " is not valid" % ev) 2334 | else: 2335 | mask = ALL_EVENTS 2336 | 2337 | # stats 2338 | cb_fun = None 2339 | if options.stats: 2340 | def cb(s): 2341 | sys.stdout.write(repr(s.proc_fun())) 2342 | sys.stdout.write('\n') 2343 | sys.stdout.write(str(s.proc_fun())) 2344 | sys.stdout.write('\n') 2345 | sys.stdout.flush() 2346 | cb_fun = cb 2347 | 2348 | # External command 2349 | if options.command: 2350 | def cb(s): 2351 | subprocess.Popen(options.command, shell=True) 2352 | cb_fun = cb 2353 | 2354 | log.debug('Start monitoring %s, (press c^c to halt pyinotify)' % path) 2355 | 2356 | wm.add_watch(path, mask, rec=options.recursive, auto_add=options.auto_add, do_glob=options.glob) 2357 | # Loop forever (until sigint signal get caught) 2358 | notifier.loop(callback=cb_fun) 2359 | 2360 | 2361 | if __name__ == '__main__': 2362 | command_line() 2363 | --------------------------------------------------------------------------------