├── .coveragerc ├── .flake8 ├── .github ├── dependabot.yml └── workflows │ └── ci-tests.yml ├── .gitignore ├── .readthedocs.yaml ├── CHANGES.rst ├── CONTRIBUTING.rst ├── CONTRIBUTORS.txt ├── LICENSE.txt ├── MANIFEST.in ├── README.rst ├── docs ├── Makefile ├── _static │ └── .keep ├── api.rst ├── changes.rst ├── conf.py ├── contributing.rst ├── index.rst └── make.bat ├── pyproject.toml ├── pytest.ini ├── setup.cfg ├── setup.py ├── src └── hupper │ ├── __init__.py │ ├── cli.py │ ├── interfaces.py │ ├── ipc.py │ ├── logger.py │ ├── polling.py │ ├── reloader.py │ ├── utils.py │ ├── watchdog.py │ ├── watchman.py │ ├── winapi.py │ └── worker.py ├── tests ├── __init__.py ├── conftest.py ├── myapp │ ├── __init__.py │ ├── __main__.py │ ├── cli.py │ └── foo.ini ├── test_cli.py ├── test_ipc.py ├── test_it.py ├── test_reloader.py └── util.py └── tox.ini /.coveragerc: -------------------------------------------------------------------------------- 1 | [run] 2 | parallel = true 3 | source = 4 | hupper 5 | tests 6 | omit = 7 | src/hupper/winapi.py 8 | 9 | [paths] 10 | source = 11 | src/hupper 12 | */site-packages/hupper 13 | 14 | [report] 15 | show_missing = true 16 | precision = 2 17 | -------------------------------------------------------------------------------- /.flake8: -------------------------------------------------------------------------------- 1 | [flake8] 2 | max-line-length = 79 3 | ignore = 4 | # E203: whitespace before ':' (black fails to be PEP8 compliant) 5 | E203 6 | # E731: do not assign a lambda expression, use a def 7 | E731 8 | # W503: line break before binary operator (flake8 is not PEP8 compliant) 9 | W503 10 | # W504: line break after binary operator (flake8 is not PEP8 compliant) 11 | W504 12 | show-source = True 13 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | # Set update schedule for GitHub Actions 2 | 3 | version: 2 4 | updates: 5 | 6 | - package-ecosystem: "github-actions" 7 | directory: "/" 8 | schedule: 9 | # Check for updates to GitHub Actions every weekday 10 | interval: "daily" 11 | -------------------------------------------------------------------------------- /.github/workflows/ci-tests.yml: -------------------------------------------------------------------------------- 1 | name: Build and test 2 | 3 | on: 4 | # Only on pushes to main or one of the release branches we build on push 5 | push: 6 | branches: 7 | - main 8 | - "[0-9].[0-9]+-branch" 9 | tags: 10 | - "*" 11 | # Build pull requests 12 | pull_request: 13 | 14 | jobs: 15 | test: 16 | strategy: 17 | matrix: 18 | py: 19 | - "3.7" 20 | - "3.8" 21 | - "3.9" 22 | - "3.10" 23 | - "3.11" 24 | - "3.12" 25 | - "pypy-3.8" 26 | os: 27 | - "ubuntu-latest" 28 | - "windows-2022" 29 | - "macos-12" 30 | architecture: 31 | - x64 32 | - x86 33 | 34 | include: 35 | # Only run coverage on ubuntu-20.04, except on pypy3 36 | - os: "ubuntu-latest" 37 | pytest-args: "--cov" 38 | - os: "ubuntu-latest" 39 | py: "pypy-3.8" 40 | pytest-args: "" 41 | 42 | exclude: 43 | # Linux and macOS don't have x86 python 44 | - os: "ubuntu-latest" 45 | architecture: x86 46 | - os: "macos-12" 47 | architecture: x86 48 | 49 | name: "Python: ${{ matrix.py }}-${{ matrix.architecture }} on ${{ matrix.os }}" 50 | runs-on: ${{ matrix.os }} 51 | steps: 52 | - uses: actions/checkout@v4 53 | - name: Setup python 54 | uses: actions/setup-python@v5 55 | with: 56 | python-version: ${{ matrix.py }} 57 | architecture: ${{ matrix.architecture }} 58 | - run: pip install tox 59 | - run: ulimit -n 4096 60 | if: ${{ runner.os == 'macOS' }} 61 | - name: Running tox 62 | run: tox -e py -- ${{ matrix.pytest-args }} 63 | coverage: 64 | runs-on: ubuntu-latest 65 | name: Validate coverage 66 | steps: 67 | - uses: actions/checkout@v4 68 | - name: Setup python 69 | uses: actions/setup-python@v5 70 | with: 71 | python-version: 3.9 72 | architecture: x64 73 | - run: pip install tox 74 | - run: tox -e py39,coverage 75 | docs: 76 | runs-on: ubuntu-latest 77 | name: Build the documentation 78 | steps: 79 | - uses: actions/checkout@v4 80 | - name: Setup python 81 | uses: actions/setup-python@v5 82 | with: 83 | python-version: 3.9 84 | architecture: x64 85 | - run: pip install tox 86 | - run: tox -e docs 87 | lint: 88 | runs-on: ubuntu-latest 89 | name: Lint the package 90 | steps: 91 | - uses: actions/checkout@v4 92 | - name: Setup python 93 | uses: actions/setup-python@v5 94 | with: 95 | python-version: 3.9 96 | architecture: x64 97 | - run: pip install tox 98 | - run: tox -e lint 99 | -------------------------------------------------------------------------------- /.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 | env/ 12 | env*/ 13 | build/ 14 | develop-eggs/ 15 | dist/ 16 | downloads/ 17 | eggs/ 18 | .eggs/ 19 | lib/ 20 | lib64/ 21 | parts/ 22 | sdist/ 23 | var/ 24 | *.egg-info/ 25 | .installed.cfg 26 | *.egg 27 | 28 | # PyInstaller 29 | # Usually these files are written by a python script from a template 30 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 31 | *.manifest 32 | *.spec 33 | 34 | # Installer logs 35 | pip-log.txt 36 | pip-delete-this-directory.txt 37 | 38 | # Unit test / coverage reports 39 | htmlcov/ 40 | .tox/ 41 | .coverage 42 | .coverage.* 43 | .cache 44 | nosetests.xml 45 | coverage.xml 46 | *,cover 47 | .hypothesis/ 48 | 49 | # Translations 50 | *.mo 51 | *.pot 52 | 53 | # Django stuff: 54 | *.log 55 | 56 | # Sphinx documentation 57 | docs/_build/ 58 | 59 | # PyBuilder 60 | target/ 61 | 62 | .idea 63 | cover 64 | -------------------------------------------------------------------------------- /.readthedocs.yaml: -------------------------------------------------------------------------------- 1 | # https://docs.readthedocs.io/en/stable/config-file/v2.html 2 | version: 2 3 | build: 4 | os: ubuntu-22.04 5 | tools: 6 | python: '3.11' 7 | sphinx: 8 | configuration: docs/conf.py 9 | python: 10 | install: 11 | - method: pip 12 | path: . 13 | extra_requirements: 14 | - docs 15 | -------------------------------------------------------------------------------- /CHANGES.rst: -------------------------------------------------------------------------------- 1 | 1.12.1 (2024-01-26) 2 | =================== 3 | 4 | - Add support for Python 3.12. 5 | 6 | - Fix a blocking issue when shutting down on Windows. 7 | 8 | - Fix a race condition closing pipes when restarting the worker process. 9 | See https://github.com/Pylons/hupper/pull/83 10 | 11 | - Fix issues with watchman when the server shuts down unexpectedly and when 12 | subscriptions are canceled. 13 | 14 | - Add ``hupper.get_reloader().graceful_shutdown()`` which can be used within 15 | your own app to trigger a full shutdown of the worker as well as the 16 | monitoring. 17 | See https://github.com/Pylons/hupper/pull/88 18 | 19 | 1.12 (2023-04-02) 20 | ================= 21 | 22 | - When the reloader is stopped, exit with the same code received from the 23 | subprocess. 24 | See https://github.com/Pylons/hupper/pull/81 25 | 26 | 1.11 (2022-01-02) 27 | ================= 28 | 29 | - Drop support for Python 2.7, 3.4, 3.5, and 3.6. 30 | 31 | - Add support/testing for Python 3.10, and 3.11. 32 | 33 | - Explicitly require ``reload_interval`` set greater than ``0`` to avoid 34 | spinning the CPU needlessly. 35 | 36 | 1.10.3 (2021-05-13) 37 | =================== 38 | 39 | - Support Python 3.8 and 3.9. 40 | 41 | - Fix an issue with bare ``.pyc`` files in the source folder causing unhandled 42 | exceptions. 43 | See https://github.com/Pylons/hupper/pull/69 44 | 45 | - Fix issues with using the Watchman file monitor on versions newer than 46 | Watchman 4.9.0. This fix modifies ``hupper`` to use Watchman's 47 | ``watch-project`` capabilities which also support reading the 48 | ``.watchmanconfig`` file to control certain properties of the monitoring. 49 | See https://github.com/Pylons/hupper/pull/70 50 | 51 | 1.10.2 (2020-03-02) 52 | =================== 53 | 54 | - Fix a regression that caused SIGINT to not work properly in some situations. 55 | See https://github.com/Pylons/hupper/pull/67 56 | 57 | 1.10.1 (2020-02-18) 58 | =================== 59 | 60 | - Performance improvements when using Watchman. 61 | 62 | 1.10 (2020-02-18) 63 | ================= 64 | 65 | - Handle a ``SIGTERM`` signal by forwarding it to the child process and 66 | gracefully waiting for it to exit. This should enable using ``hupper`` 67 | from within docker containers and other systems that want to control 68 | the reloader process. 69 | 70 | Previously the ``SIGTERM`` would shutdown ``hupper`` immediately, stranding 71 | the worker and relying on it to shutdown on its own. 72 | 73 | See https://github.com/Pylons/hupper/pull/65 74 | 75 | - Avoid acquiring locks in the reloader process's signal handlers. 76 | See https://github.com/Pylons/hupper/pull/65 77 | 78 | - Fix deprecation warnings caused by using the ``imp`` module on newer 79 | versions of Python. 80 | See https://github.com/Pylons/hupper/pull/65 81 | 82 | 1.9.1 (2019-11-12) 83 | ================== 84 | 85 | - Support some scenarios in which user code is symlinked ``site-packages``. 86 | These were previously being ignored by the file monitor but should now 87 | be tracked. 88 | See https://github.com/Pylons/hupper/pull/61 89 | 90 | 1.9 (2019-10-14) 91 | ================ 92 | 93 | - Support ``--shutdown-interval`` on the ``hupper`` CLI. 94 | See https://github.com/Pylons/hupper/pull/56 95 | 96 | - Support ``--reload-interval`` on the ``hupper`` CLI. 97 | See https://github.com/Pylons/hupper/pull/59 98 | 99 | - Do not choke when stdin is not a TTY while waiting for changes after a 100 | crash. For example, when running in Docker Compose. 101 | See https://github.com/Pylons/hupper/pull/58 102 | 103 | 1.8.1 (2019-06-12) 104 | ================== 105 | 106 | - Do not show the ``KeyboardInterrupt`` stacktrace when killing ``hupper`` 107 | while waiting for a reload. 108 | 109 | 1.8 (2019-06-11) 110 | ================ 111 | 112 | - If the worker process crashes, ``hupper`` can be forced to reload the worker 113 | by pressing the ``ENTER`` key in the terminal instead of waiting to change a 114 | file. 115 | See https://github.com/Pylons/hupper/pull/53 116 | 117 | 1.7 (2019-06-04) 118 | ================ 119 | 120 | - On Python 3.5+ support recursive glob syntax in ``reloader.watch_files``. 121 | See https://github.com/Pylons/hupper/pull/52 122 | 123 | 1.6.1 (2019-03-11) 124 | ================== 125 | 126 | - If the worker crashes immediately, sometimes ``hupper`` would go into a 127 | restart loop instead of waiting for a code change. 128 | See https://github.com/Pylons/hupper/pull/50 129 | 130 | 1.6 (2019-03-06) 131 | ================ 132 | 133 | - On systems that support ``SIGKILL`` and ``SIGTERM`` (not Windows), ``hupper`` 134 | will now send a ``SIGKILL`` to the worker process as a last resort. Normally, 135 | a ``SIGINT`` (Ctrl-C) or ``SIGTERM`` (on reload) will kill the worker. If, 136 | within ``shutdown_interval`` seconds, the worker doesn't exit, it will 137 | receive a ``SIGKILL``. 138 | See https://github.com/Pylons/hupper/pull/48 139 | 140 | - Support a ``logger`` argument to ``hupper.start_reloader`` to override 141 | the default logger that outputs messages to ``sys.stderr``. 142 | See https://github.com/Pylons/hupper/pull/49 143 | 144 | 1.5 (2019-02-16) 145 | ================ 146 | 147 | - Add support for ignoring custom patterns via the new ``ignore_files`` 148 | option on ``hupper.start_reloader``. The ``hupper`` cli also supports 149 | ignoring files via the ``-x`` option. 150 | See https://github.com/Pylons/hupper/pull/46 151 | 152 | 1.4.2 (2018-11-26) 153 | ================== 154 | 155 | - Fix a bug prompting the "ignoring corrupted payload from watchman" message 156 | and placing the file monitor in an unrecoverable state when a change 157 | triggered a watchman message > 4096 bytes. 158 | See https://github.com/Pylons/hupper/pull/44 159 | 160 | 1.4.1 (2018-11-11) 161 | ================== 162 | 163 | - Stop ignoring a few paths that may not be system paths in cases where the 164 | virtualenv is the root of your project. 165 | See https://github.com/Pylons/hupper/pull/42 166 | 167 | 1.4 (2018-10-26) 168 | ================ 169 | 170 | - Ignore changes to any system / installed files. This includes mostly 171 | changes to any files in the stdlib and ``site-packages``. Anything that is 172 | installed in editable mode or not installed at all will still be monitored. 173 | This drastically reduces the number of files that ``hupper`` needs to 174 | monitor. 175 | See https://github.com/Pylons/hupper/pull/40 176 | 177 | 1.3.1 (2018-10-05) 178 | ================== 179 | 180 | - Support Python 3.7. 181 | 182 | - Avoid a restart-loop if the app is failing to restart on certain systems. 183 | There was a race where ``hupper`` failed to detect that the app was 184 | crashing and thus fell into its restart logic when the user manually 185 | triggers an immediate reload. 186 | See https://github.com/Pylons/hupper/pull/37 187 | 188 | - Ignore corrupted packets coming from watchman that occur in semi-random 189 | scenarios. See https://github.com/Pylons/hupper/pull/38 190 | 191 | 1.3 (2018-05-21) 192 | ================ 193 | 194 | - Added watchman support via ``hupper.watchman.WatchmanFileMonitor``. 195 | This is the new preferred file monitor on systems supporting unix sockets. 196 | See https://github.com/Pylons/hupper/pull/32 197 | 198 | - The ``hupper.watchdog.WatchdogFileMonitor`` will now output some info 199 | when it receives ulimit or other errors from ``watchdog``. 200 | See https://github.com/Pylons/hupper/pull/33 201 | 202 | - Allow ``-q`` and ``-v`` cli options to control verbosity. 203 | See https://github.com/Pylons/hupper/pull/33 204 | 205 | - Pass a ``logger`` value to the ``hupper.interfaces.IFileMonitorFactory``. 206 | This is an instance of ``hupper.interfaces.ILogger`` and can be used by 207 | file monitors to output errors and debug information. 208 | See https://github.com/Pylons/hupper/pull/33 209 | 210 | 1.2 (2018-05-01) 211 | ================ 212 | 213 | - Track only Python source files. Previously ``hupper`` would track all pyc 214 | and py files. Now, if a pyc file is found then the equivalent source file 215 | is searched and, if found, the pyc file is ignored. 216 | See https://github.com/Pylons/hupper/pull/31 217 | 218 | - Allow overriding the default monitor lookup by specifying the 219 | ``HUPPER_DEFAULT_MONITOR`` environment variable as a Python dotted-path 220 | to a monitor factory. For example, 221 | ``HUPPER_DEFAULT_MONITOR=hupper.polling.PollingFileMonitor``. 222 | See https://github.com/Pylons/hupper/pull/29 223 | 224 | - Backward-incompatible changes to the 225 | ``hupper.interfaces.IFileMonitorFactory`` API to pass arbitrary kwargs 226 | to the factory. 227 | See https://github.com/Pylons/hupper/pull/29 228 | 229 | 1.1 (2018-03-29) 230 | ================ 231 | 232 | - Support ``-w`` on the CLI to watch custom file paths. 233 | See https://github.com/Pylons/hupper/pull/28 234 | 235 | 1.0 (2017-05-18) 236 | ================ 237 | 238 | - Copy ``sys.path`` to the worker process and ensure ``hupper`` is on the 239 | ``PYTHONPATH`` so that the subprocess can import it to start the worker. 240 | This fixes an issue with how ``zc.buildout`` injects dependencies into a 241 | process which is done entirely by ``sys.path`` manipulation. 242 | See https://github.com/Pylons/hupper/pull/27 243 | 244 | 0.5 (2017-05-10) 245 | ================ 246 | 247 | - On non-windows systems ensure an exec occurs so that the worker does not 248 | share the same process space as the reloader causing certain code that 249 | is imported in both to not ever be reloaded. Under the hood this was a 250 | significant rewrite to use subprocess instead of multiprocessing. 251 | See https://github.com/Pylons/hupper/pull/23 252 | 253 | 0.4.4 (2017-03-10) 254 | ================== 255 | 256 | - Fix some versions of Windows which were failing to duplicate stdin to 257 | the subprocess and crashing. 258 | https://github.com/Pylons/hupper/pull/16 259 | 260 | 0.4.3 (2017-03-07) 261 | ================== 262 | 263 | - Fix pdb and other readline-based programs to operate properly. 264 | See https://github.com/Pylons/hupper/pull/15 265 | 266 | 0.4.2 (2017-01-24) 267 | ================== 268 | 269 | - Pause briefly after receiving a SIGINT to allow the worker to kill itself. 270 | If it does not die then it is terminated. 271 | See https://github.com/Pylons/hupper/issues/11 272 | 273 | - Python 3.6 compatibility. 274 | 275 | 0.4.1 (2017-01-03) 276 | ================== 277 | 278 | - Handle errors that may occur when using watchdog to observe non-existent 279 | folders. 280 | 281 | 0.4.0 (2017-01-02) 282 | ================== 283 | 284 | - Support running any Python module via ``hupper -m ``. This is 285 | equivalent to ``python -m`` except will fully reload the process when files 286 | change. See https://github.com/Pylons/hupper/pull/8 287 | 288 | 0.3.6 (2016-12-18) 289 | ================== 290 | 291 | - Read the traceback for unknown files prior to crashing. If an import 292 | crashes due to a module-scope exception the file that caused the crash would 293 | not be tracked but this should help. 294 | 295 | 0.3.5 (2016-12-17) 296 | ================== 297 | 298 | - Attempt to send imported paths to the monitor process before crashing to 299 | avoid cases where the master is waiting for changes in files that it never 300 | started monitoring. 301 | 302 | 0.3.4 (2016-11-21) 303 | ================== 304 | 305 | - Add support for globbing using the stdlib ``glob`` module. On Python 3.5+ 306 | this allows recursive globs using ``**``. Prior to this, the globbing is 307 | more limited. 308 | 309 | 0.3.3 (2016-11-19) 310 | ================== 311 | 312 | - Fixed a runtime failure on Windows 32-bit systems. 313 | 314 | 0.3.2 (2016-11-15) 315 | ================== 316 | 317 | - Support triggering reloads via SIGHUP when hupper detected a crash and is 318 | waiting for a file to change. 319 | 320 | - Setup the reloader proxy prior to importing the worker's module. This 321 | should allow some work to be done at module-scope instead of in the 322 | callable. 323 | 324 | 0.3.1 (2016-11-06) 325 | ================== 326 | 327 | - Fix package long description on PyPI. 328 | 329 | - Ensure that the stdin file handle is inheritable incase the "spawn" variant 330 | of multiprocessing is enabled. 331 | 332 | 0.3 (2016-11-06) 333 | ================ 334 | 335 | - Disable bytecode compiling of files imported by the worker process. This 336 | should not be necessary when developing and it was causing the process to 337 | restart twice on Windows due to how it handles pyc timestamps. 338 | 339 | - Fix hupper's support for forwarding stdin to the worker processes on 340 | Python < 3.5 on Windows. 341 | 342 | - Fix some possible file descriptor leakage. 343 | 344 | - Simplify the ``hupper.interfaces.IFileMonitor`` interface by internalizing 345 | some of the hupper-specific integrations. They can now focus on just 346 | looking for changes. 347 | 348 | - Add the ``hupper.interfaces.IFileMonitorFactory`` interface to improve 349 | the documentation for the ``callback`` argument required by 350 | ``hupper.interfaces.IFileMonitor``. 351 | 352 | 0.2 (2016-10-26) 353 | ================ 354 | 355 | - Windows support! 356 | 357 | - Added support for `watchdog `_ if it's 358 | installed to do inotify-style file monitoring. This is an optional dependency 359 | and ``hupper`` will fallback to using polling if it's not available. 360 | 361 | 0.1 (2016-10-21) 362 | ================ 363 | 364 | - Initial release. 365 | -------------------------------------------------------------------------------- /CONTRIBUTING.rst: -------------------------------------------------------------------------------- 1 | .. highlight:: shell 2 | 3 | ============ 4 | Contributing 5 | ============ 6 | 7 | Contributions are welcome, and they are greatly appreciated! Every 8 | little bit helps, and credit will always be given. 9 | 10 | You can contribute in many ways: 11 | 12 | Types of Contributions 13 | ---------------------- 14 | 15 | Report Bugs 16 | ~~~~~~~~~~~ 17 | 18 | Report bugs at https://github.com/Pylons/hupper/issues. 19 | 20 | If you are reporting a bug, please include: 21 | 22 | * Your operating system name and version. 23 | * Any details about your local setup that might be helpful in troubleshooting. 24 | * Detailed steps to reproduce the bug. 25 | 26 | Fix Bugs 27 | ~~~~~~~~ 28 | 29 | Look through the GitHub issues for bugs. Anything tagged with "bug" 30 | is open to whoever wants to implement it. 31 | 32 | Implement Features 33 | ~~~~~~~~~~~~~~~~~~ 34 | 35 | Look through the GitHub issues for features. Anything tagged with "feature" 36 | is open to whoever wants to implement it. 37 | 38 | Write Documentation 39 | ~~~~~~~~~~~~~~~~~~~ 40 | 41 | hupper could always use more documentation, whether as part of the 42 | official hupper docs, in docstrings, or even on the web in blog posts, 43 | articles, and such. 44 | 45 | Submit Feedback 46 | ~~~~~~~~~~~~~~~ 47 | 48 | The best way to send feedback is to file an issue at 49 | https://github.com/Pylons/hupper/issues. 50 | 51 | If you are proposing a feature: 52 | 53 | * Explain in detail how it would work. 54 | * Keep the scope as narrow as possible, to make it easier to implement. 55 | * Remember that this is a volunteer-driven project, and that contributions 56 | are welcome :) 57 | 58 | Get Started! 59 | ------------ 60 | 61 | Ready to contribute? Here's how to set up `hupper` for local development. 62 | 63 | 1. Fork the `hupper` repo on GitHub. 64 | 2. Clone your fork locally:: 65 | 66 | $ git clone git@github.com:your_name_here/hupper.git 67 | 68 | 3. Install your local copy into a virtualenv:: 69 | 70 | $ python3 -m venv env 71 | $ env/bin/pip install -e .[docs,testing] 72 | $ env/bin/pip install tox 73 | 74 | 4. Create a branch for local development:: 75 | 76 | $ git checkout -b name-of-your-bugfix-or-feature 77 | 78 | Now you can make your changes locally. 79 | 80 | 5. When you're done making changes, check that your changes pass flake8 and 81 | the tests, including testing other Python versions with tox:: 82 | 83 | $ env/bin/tox 84 | 85 | 6. Add your name to the ``CONTRIBUTORS.txt`` file in the root of the 86 | repository. 87 | 88 | 7. Commit your changes and push your branch to GitHub:: 89 | 90 | $ git add . 91 | $ git commit -m "Your detailed description of your changes." 92 | $ git push origin name-of-your-bugfix-or-feature 93 | 94 | 8. Submit a pull request through the GitHub website. 95 | 96 | Pull Request Guidelines 97 | ----------------------- 98 | 99 | Before you submit a pull request, check that it meets these guidelines: 100 | 101 | 1. The pull request should include tests. 102 | 2. If the pull request adds functionality, the docs should be updated. Put 103 | your new functionality into a function with a docstring, and add the 104 | feature to the list in README.rst. 105 | 3. The pull request should work for Python 3.7 and up and for PyPy 3.8. 106 | 4. When your pull request is posted, a maintainer will click the button to run 107 | Github Actions, afterwards validate that your PR is valid for all tested 108 | platforms/Python versions 109 | 110 | Tips 111 | ---- 112 | 113 | To run a subset of tests:: 114 | 115 | $ env/bin/py.test tests.test_hupper 116 | -------------------------------------------------------------------------------- /CONTRIBUTORS.txt: -------------------------------------------------------------------------------- 1 | Pylons Project Contributor Agreement 2 | ==================================== 3 | 4 | The submitter agrees by adding his or her name within the section below named 5 | "Contributors" and submitting the resulting modified document to the 6 | canonical shared repository location for this software project (whether 7 | directly, as a user with "direct commit access", or via a "pull request"), he 8 | or she is signing a contract electronically. The submitter becomes a 9 | Contributor after a) he or she signs this document by adding their name 10 | beneath the "Contributors" section below, and b) the resulting document is 11 | accepted into the canonical version control repository. 12 | 13 | Treatment of Account 14 | -------------------- 15 | 16 | Contributor will not allow anyone other than the Contributor to use his or 17 | her username or source repository login to submit code to a Pylons Project 18 | source repository. Should Contributor become aware of any such use, 19 | Contributor will immediately notify Agendaless Consulting. 20 | Notification must be performed by sending an email to 21 | webmaster@agendaless.com. Until such notice is received, Contributor will be 22 | presumed to have taken all actions made through Contributor's account. If the 23 | Contributor has direct commit access, Agendaless Consulting will have 24 | complete control and discretion over capabilities assigned to Contributor's 25 | account, and may disable Contributor's account for any reason at any time. 26 | 27 | Legal Effect of Contribution 28 | ---------------------------- 29 | 30 | Upon submitting a change or new work to a Pylons Project source Repository (a 31 | "Contribution"), you agree to assign, and hereby do assign, a one-half 32 | interest of all right, title and interest in and to copyright and other 33 | intellectual property rights with respect to your new and original portions 34 | of the Contribution to Agendaless Consulting. You and Agendaless Consulting 35 | each agree that the other shall be free to exercise any and all exclusive 36 | rights in and to the Contribution, without accounting to one another, 37 | including without limitation, the right to license the Contribution to others 38 | under the MIT License. This agreement shall run with title to the 39 | Contribution. Agendaless Consulting does not convey to you any right, title 40 | or interest in or to the Program or such portions of the Contribution that 41 | were taken from the Program. Your transmission of a submission to the Pylons 42 | Project source Repository and marks of identification concerning the 43 | Contribution itself constitute your intent to contribute and your assignment 44 | of the work in accordance with the provisions of this Agreement. 45 | 46 | License Terms 47 | ------------- 48 | 49 | Code committed to the Pylons Project source repository (Committed Code) must 50 | be governed by the MIT License or another license acceptable to 51 | Agendaless Consulting. Until Agendaless Consulting declares in writing an 52 | acceptable license other than the MIT License, only the MIT License shall be 53 | used. A list of exceptions is detailed within 54 | the "Licensing Exceptions" section of this document, if one exists. 55 | 56 | Representations, Warranty, and Indemnification 57 | ---------------------------------------------- 58 | 59 | Contributor represents and warrants that the Committed Code does not violate 60 | the rights of any person or entity, and that the Contributor has legal 61 | authority to enter into this Agreement and legal authority over Contributed 62 | Code. Further, Contributor indemnifies Agendaless Consulting against 63 | violations. 64 | 65 | Cryptography 66 | ------------ 67 | 68 | Contributor understands that cryptographic code may be subject to government 69 | regulations with which Agendaless Consulting and/or entities using Committed 70 | Code must comply. Any code which contains any of the items listed below must 71 | not be checked-in until Agendaless Consulting staff has been notified and has 72 | approved such contribution in writing. 73 | 74 | - Cryptographic capabilities or features 75 | 76 | - Calls to cryptographic features 77 | 78 | - User interface elements which provide context relating to cryptography 79 | 80 | - Code which may, under casual inspection, appear to be cryptographic. 81 | 82 | Notices 83 | ------- 84 | 85 | Contributor confirms that any notices required will be included in any 86 | Committed Code. 87 | 88 | Licensing Exceptions 89 | ==================== 90 | 91 | Code committed within the ``docs/`` subdirectory of the hupper source 92 | control repository and "docstrings" which appear in the documentation 93 | generated by running "make" within this directory are licensed under the 94 | Creative Commons Attribution-Noncommercial-Share Alike 3.0 United States 95 | License (http://creativecommons.org/licenses/by-nc-sa/3.0/us/). 96 | 97 | List of Contributors 98 | ==================== 99 | 100 | The below-signed are contributors to a code repository that is part of the 101 | project named "hupper". Each below-signed contributor has read, understand 102 | and agrees to the terms above in the section within this document entitled 103 | "Pylons Project Contributor Agreement" as of the date beside his or her name. 104 | 105 | Contributors 106 | ------------ 107 | 108 | - Michael Merickel (2016-10-21) 109 | - Bert JW Regeer (2017-05-17) 110 | - Jens Carl (2017-05-22) 111 | - Eric Atkin (2019-02-15) 112 | - Yeray Díaz Díaz (2019-10-03) 113 | - Marcel Jackwerth (2023-03-23) 114 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | Copyright (c) 2017 Michael Merickel 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy 4 | of this software and associated documentation files (the "Software"), to deal 5 | in the Software without restriction, including without limitation the rights 6 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 7 | copies of the Software, and to permit persons to whom the Software is 8 | furnished to do so, subject to the following conditions: 9 | 10 | The above copyright notice and this permission notice shall be included in all 11 | copies or substantial portions of the Software. 12 | 13 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 19 | SOFTWARE. 20 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | graft src/hupper 2 | graft tests 3 | graft docs 4 | graft .github 5 | prune docs/_build 6 | 7 | include README.rst 8 | include CHANGES.rst 9 | include LICENSE.txt 10 | include CONTRIBUTING.rst 11 | include CONTRIBUTORS.txt 12 | 13 | include pyproject.toml 14 | include setup.cfg 15 | include .coveragerc 16 | include .flake8 17 | include tox.ini 18 | include pytest.ini 19 | include .readthedocs.yaml 20 | 21 | recursive-exclude * __pycache__ *.py[cod] 22 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | ====== 2 | hupper 3 | ====== 4 | 5 | .. image:: https://img.shields.io/pypi/v/hupper.svg 6 | :target: https://pypi.python.org/pypi/hupper 7 | 8 | .. image:: https://github.com/Pylons/hupper/actions/workflows/ci-tests.yml/badge.svg?branch=main 9 | :target: https://github.com/Pylons/hupper/actions/workflows/ci-tests.yml?query=branch%3Amain 10 | 11 | .. image:: https://readthedocs.org/projects/hupper/badge/?version=latest 12 | :target: https://readthedocs.org/projects/hupper/?badge=latest 13 | :alt: Documentation Status 14 | 15 | ``hupper`` is an integrated process monitor that will track changes to 16 | any imported Python files in ``sys.modules`` as well as custom paths. When 17 | files are changed the process is restarted. 18 | 19 | Command-line Usage 20 | ================== 21 | 22 | Hupper can load any Python code similar to ``python -m `` by using the 23 | ``hupper -m `` program. 24 | 25 | .. code-block:: console 26 | 27 | $ hupper -m myapp 28 | Starting monitor for PID 23982. 29 | 30 | API Usage 31 | ========= 32 | 33 | Start by defining an entry point for your process. This must be an importable 34 | path in string format. For example, ``myapp.scripts.serve.main``. 35 | 36 | .. code-block:: python 37 | 38 | # myapp/scripts/serve.py 39 | 40 | import sys 41 | import hupper 42 | import waitress 43 | 44 | 45 | def wsgi_app(environ, start_response): 46 | start_response('200 OK', [('Content-Type', 'text/plain')]) 47 | yield b'hello' 48 | 49 | 50 | def main(args=sys.argv[1:]): 51 | if '--reload' in args: 52 | # start_reloader will only return in a monitored subprocess 53 | reloader = hupper.start_reloader('myapp.scripts.serve.main') 54 | 55 | # monitor an extra file 56 | reloader.watch_files(['foo.ini']) 57 | 58 | waitress.serve(wsgi_app) 59 | 60 | Acknowledgments 61 | =============== 62 | 63 | ``hupper`` is inspired by initial work done by Carl J Meyer and David Glick 64 | during a Pycon sprint and is built to be a more robust and generic version of 65 | Ian Bicking's excellent PasteScript ``paste serve --reload`` and Pyramid's 66 | ``pserve --reload``. 67 | -------------------------------------------------------------------------------- /docs/Makefile: -------------------------------------------------------------------------------- 1 | # Makefile for Sphinx documentation 2 | # 3 | 4 | # You can set these variables from the command line. 5 | SPHINXOPTS = 6 | SPHINXBUILD = sphinx-build 7 | PAPER = 8 | BUILDDIR = _build 9 | 10 | # User-friendly check for sphinx-build 11 | ifeq ($(shell which $(SPHINXBUILD) >/dev/null 2>&1; echo $$?), 1) 12 | $(error The '$(SPHINXBUILD)' command was not found. Make sure you have Sphinx installed, then set the SPHINXBUILD environment variable to point to the full path of the '$(SPHINXBUILD)' executable. Alternatively you can add the directory with the executable to your PATH. If you don't have Sphinx installed, grab it from http://sphinx-doc.org/) 13 | endif 14 | 15 | # Internal variables. 16 | PAPEROPT_a4 = -D latex_paper_size=a4 17 | PAPEROPT_letter = -D latex_paper_size=letter 18 | ALLSPHINXOPTS = -d $(BUILDDIR)/doctrees $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) . 19 | # the i18n builder cannot share the environment and doctrees with the others 20 | I18NSPHINXOPTS = $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) . 21 | 22 | .PHONY: help clean html dirhtml singlehtml pickle json htmlhelp qthelp devhelp epub latex latexpdf text man changes linkcheck doctest gettext 23 | 24 | help: 25 | @echo "Please use \`make ' where is one of" 26 | @echo " html to make standalone HTML files" 27 | @echo " dirhtml to make HTML files named index.html in directories" 28 | @echo " singlehtml to make a single large HTML file" 29 | @echo " pickle to make pickle files" 30 | @echo " json to make JSON files" 31 | @echo " htmlhelp to make HTML files and a HTML help project" 32 | @echo " qthelp to make HTML files and a qthelp project" 33 | @echo " devhelp to make HTML files and a Devhelp project" 34 | @echo " epub to make an epub" 35 | @echo " latex to make LaTeX files, you can set PAPER=a4 or PAPER=letter" 36 | @echo " latexpdf to make LaTeX files and run them through pdflatex" 37 | @echo " latexpdfja to make LaTeX files and run them through platex/dvipdfmx" 38 | @echo " text to make text files" 39 | @echo " man to make manual pages" 40 | @echo " texinfo to make Texinfo files" 41 | @echo " info to make Texinfo files and run them through makeinfo" 42 | @echo " gettext to make PO message catalogs" 43 | @echo " changes to make an overview of all changed/added/deprecated items" 44 | @echo " xml to make Docutils-native XML files" 45 | @echo " pseudoxml to make pseudoxml-XML files for display purposes" 46 | @echo " linkcheck to check all external links for integrity" 47 | @echo " doctest to run all doctests embedded in the documentation (if enabled)" 48 | 49 | clean: 50 | rm -rf $(BUILDDIR)/* 51 | 52 | html: 53 | $(SPHINXBUILD) -b html $(ALLSPHINXOPTS) $(BUILDDIR)/html 54 | @echo 55 | @echo "Build finished. The HTML pages are in $(BUILDDIR)/html." 56 | 57 | dirhtml: 58 | $(SPHINXBUILD) -b dirhtml $(ALLSPHINXOPTS) $(BUILDDIR)/dirhtml 59 | @echo 60 | @echo "Build finished. The HTML pages are in $(BUILDDIR)/dirhtml." 61 | 62 | singlehtml: 63 | $(SPHINXBUILD) -b singlehtml $(ALLSPHINXOPTS) $(BUILDDIR)/singlehtml 64 | @echo 65 | @echo "Build finished. The HTML page is in $(BUILDDIR)/singlehtml." 66 | 67 | pickle: 68 | $(SPHINXBUILD) -b pickle $(ALLSPHINXOPTS) $(BUILDDIR)/pickle 69 | @echo 70 | @echo "Build finished; now you can process the pickle files." 71 | 72 | json: 73 | $(SPHINXBUILD) -b json $(ALLSPHINXOPTS) $(BUILDDIR)/json 74 | @echo 75 | @echo "Build finished; now you can process the JSON files." 76 | 77 | htmlhelp: 78 | $(SPHINXBUILD) -b htmlhelp $(ALLSPHINXOPTS) $(BUILDDIR)/htmlhelp 79 | @echo 80 | @echo "Build finished; now you can run HTML Help Workshop with the" \ 81 | ".hhp project file in $(BUILDDIR)/htmlhelp." 82 | 83 | qthelp: 84 | $(SPHINXBUILD) -b qthelp $(ALLSPHINXOPTS) $(BUILDDIR)/qthelp 85 | @echo 86 | @echo "Build finished; now you can run "qcollectiongenerator" with the" \ 87 | ".qhcp project file in $(BUILDDIR)/qthelp, like this:" 88 | @echo "# qcollectiongenerator $(BUILDDIR)/qthelp/hupper.qhcp" 89 | @echo "To view the help file:" 90 | @echo "# assistant -collectionFile $(BUILDDIR)/qthelp/hupper.qhc" 91 | 92 | devhelp: 93 | $(SPHINXBUILD) -b devhelp $(ALLSPHINXOPTS) $(BUILDDIR)/devhelp 94 | @echo 95 | @echo "Build finished." 96 | @echo "To view the help file:" 97 | @echo "# mkdir -p $$HOME/.local/share/devhelp/hupper" 98 | @echo "# ln -s $(BUILDDIR)/devhelp $$HOME/.local/share/devhelp/hupper" 99 | @echo "# devhelp" 100 | 101 | epub: 102 | $(SPHINXBUILD) -b epub $(ALLSPHINXOPTS) $(BUILDDIR)/epub 103 | @echo 104 | @echo "Build finished. The epub file is in $(BUILDDIR)/epub." 105 | 106 | latex: 107 | $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex 108 | @echo 109 | @echo "Build finished; the LaTeX files are in $(BUILDDIR)/latex." 110 | @echo "Run \`make' in that directory to run these through (pdf)latex" \ 111 | "(use \`make latexpdf' here to do that automatically)." 112 | 113 | latexpdf: 114 | $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex 115 | @echo "Running LaTeX files through pdflatex..." 116 | $(MAKE) -C $(BUILDDIR)/latex all-pdf 117 | @echo "pdflatex finished; the PDF files are in $(BUILDDIR)/latex." 118 | 119 | latexpdfja: 120 | $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex 121 | @echo "Running LaTeX files through platex and dvipdfmx..." 122 | $(MAKE) -C $(BUILDDIR)/latex all-pdf-ja 123 | @echo "pdflatex finished; the PDF files are in $(BUILDDIR)/latex." 124 | 125 | text: 126 | $(SPHINXBUILD) -b text $(ALLSPHINXOPTS) $(BUILDDIR)/text 127 | @echo 128 | @echo "Build finished. The text files are in $(BUILDDIR)/text." 129 | 130 | man: 131 | $(SPHINXBUILD) -b man $(ALLSPHINXOPTS) $(BUILDDIR)/man 132 | @echo 133 | @echo "Build finished. The manual pages are in $(BUILDDIR)/man." 134 | 135 | texinfo: 136 | $(SPHINXBUILD) -b texinfo $(ALLSPHINXOPTS) $(BUILDDIR)/texinfo 137 | @echo 138 | @echo "Build finished. The Texinfo files are in $(BUILDDIR)/texinfo." 139 | @echo "Run \`make' in that directory to run these through makeinfo" \ 140 | "(use \`make info' here to do that automatically)." 141 | 142 | info: 143 | $(SPHINXBUILD) -b texinfo $(ALLSPHINXOPTS) $(BUILDDIR)/texinfo 144 | @echo "Running Texinfo files through makeinfo..." 145 | make -C $(BUILDDIR)/texinfo info 146 | @echo "makeinfo finished; the Info files are in $(BUILDDIR)/texinfo." 147 | 148 | gettext: 149 | $(SPHINXBUILD) -b gettext $(I18NSPHINXOPTS) $(BUILDDIR)/locale 150 | @echo 151 | @echo "Build finished. The message catalogs are in $(BUILDDIR)/locale." 152 | 153 | changes: 154 | $(SPHINXBUILD) -b changes $(ALLSPHINXOPTS) $(BUILDDIR)/changes 155 | @echo 156 | @echo "The overview file is in $(BUILDDIR)/changes." 157 | 158 | linkcheck: 159 | $(SPHINXBUILD) -b linkcheck $(ALLSPHINXOPTS) $(BUILDDIR)/linkcheck 160 | @echo 161 | @echo "Link check complete; look for any errors in the above output " \ 162 | "or in $(BUILDDIR)/linkcheck/output.txt." 163 | 164 | doctest: 165 | $(SPHINXBUILD) -b doctest $(ALLSPHINXOPTS) $(BUILDDIR)/doctest 166 | @echo "Testing of doctests in the sources finished, look at the " \ 167 | "results in $(BUILDDIR)/doctest/output.txt." 168 | 169 | xml: 170 | $(SPHINXBUILD) -b xml $(ALLSPHINXOPTS) $(BUILDDIR)/xml 171 | @echo 172 | @echo "Build finished. The XML files are in $(BUILDDIR)/xml." 173 | 174 | pseudoxml: 175 | $(SPHINXBUILD) -b pseudoxml $(ALLSPHINXOPTS) $(BUILDDIR)/pseudoxml 176 | @echo 177 | @echo "Build finished. The pseudo-XML files are in $(BUILDDIR)/pseudoxml." 178 | -------------------------------------------------------------------------------- /docs/_static/.keep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Pylons/hupper/b2c564c915bbb94999602820a587f39be02eee29/docs/_static/.keep -------------------------------------------------------------------------------- /docs/api.rst: -------------------------------------------------------------------------------- 1 | ================== 2 | :mod:`hupper` API 3 | ================== 4 | 5 | .. automodule:: hupper 6 | 7 | .. autofunction:: start_reloader 8 | 9 | .. autofunction:: is_active 10 | 11 | .. autofunction:: get_reloader 12 | 13 | .. autofunction:: is_watchdog_supported 14 | 15 | .. autofunction:: is_watchman_supported 16 | 17 | .. automodule:: hupper.reloader 18 | 19 | .. autoclass:: Reloader 20 | :members: 21 | 22 | .. automodule:: hupper.interfaces 23 | 24 | .. autoclass:: IReloaderProxy 25 | :members: 26 | :special-members: 27 | 28 | .. autoclass:: IFileMonitor 29 | :members: 30 | :special-members: 31 | 32 | .. autoclass:: IFileMonitorFactory 33 | :members: 34 | :special-members: 35 | 36 | .. autoclass:: ILogger 37 | :members: 38 | :special-members: 39 | 40 | .. automodule:: hupper.polling 41 | 42 | .. autoclass:: PollingFileMonitor 43 | 44 | .. automodule:: hupper.watchdog 45 | 46 | .. autoclass:: WatchdogFileMonitor 47 | 48 | .. automodule:: hupper.watchman 49 | 50 | .. autoclass:: WatchmanFileMonitor 51 | -------------------------------------------------------------------------------- /docs/changes.rst: -------------------------------------------------------------------------------- 1 | ======= 2 | Changes 3 | ======= 4 | 5 | .. include:: ../CHANGES.rst 6 | -------------------------------------------------------------------------------- /docs/conf.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | # 4 | # hupper documentation build configuration file, created by 5 | # sphinx-quickstart on Tue Jul 9 22:26:36 2013. 6 | # 7 | # This file is execfile()d with the current directory set to its 8 | # containing dir. 9 | # 10 | # Note that not all possible configuration values are present in this 11 | # autogenerated file. 12 | # 13 | # All configuration values have a default; values that are commented out 14 | # serve to show the default. 15 | 16 | import sys 17 | import os 18 | import pkg_resources 19 | 20 | # If extensions (or modules to document with autodoc) are in another 21 | # directory, add these directories to sys.path here. If the directory is 22 | # relative to the documentation root, use os.path.abspath to make it 23 | # absolute, like shown here. 24 | #sys.path.insert(0, os.path.abspath('.')) 25 | 26 | # Get the project root dir, which is the parent dir of this 27 | cwd = os.getcwd() 28 | project_root = os.path.dirname(cwd) 29 | 30 | # Insert the project root dir as the first element in the PYTHONPATH. 31 | # This lets us ensure that the source package is imported, and that its 32 | # version is used. 33 | sys.path.insert(0, project_root) 34 | 35 | # ensure the code is importable for use with autodoc 36 | import hupper 37 | 38 | # -- General configuration --------------------------------------------- 39 | 40 | # If your documentation needs a minimal Sphinx version, state it here. 41 | #needs_sphinx = '1.0' 42 | 43 | # Add any Sphinx extension module names here, as strings. They can be 44 | # extensions coming with Sphinx (named 'sphinx.ext.*') or your custom ones. 45 | extensions = [ 46 | 'sphinx.ext.autodoc', 47 | 'sphinx.ext.viewcode', 48 | 'sphinx.ext.intersphinx', 49 | ] 50 | 51 | # Add any paths that contain templates here, relative to this directory. 52 | templates_path = ['_templates'] 53 | 54 | # The suffix of source filenames. 55 | source_suffix = '.rst' 56 | 57 | # The encoding of source files. 58 | #source_encoding = 'utf-8-sig' 59 | 60 | # The master toctree document. 61 | master_doc = 'index' 62 | 63 | # General information about the project. 64 | project = u'hupper' 65 | copyright = u'2017, Michael Merickel' 66 | 67 | # The version info for the project you're documenting, acts as replacement 68 | # for |version| and |release|, also used in various other places throughout 69 | # the built documents. 70 | # 71 | # The short X.Y version. 72 | version = pkg_resources.get_distribution('hupper').version 73 | # The full version, including alpha/beta/rc tags. 74 | release = version 75 | 76 | # The language for content autogenerated by Sphinx. Refer to documentation 77 | # for a list of supported languages. 78 | #language = None 79 | 80 | # There are two options for replacing |today|: either, you set today to 81 | # some non-false value, then it is used: 82 | #today = '' 83 | # Else, today_fmt is used as the format for a strftime call. 84 | #today_fmt = '%B %d, %Y' 85 | 86 | # List of patterns, relative to source directory, that match files and 87 | # directories to ignore when looking for source files. 88 | exclude_patterns = ['_build'] 89 | 90 | # The reST default role (used for this markup: `text`) to use for all 91 | # documents. 92 | #default_role = None 93 | 94 | # If true, '()' will be appended to :func: etc. cross-reference text. 95 | #add_function_parentheses = True 96 | 97 | # If true, the current module name will be prepended to all description 98 | # unit titles (such as .. function::). 99 | #add_module_names = True 100 | 101 | # If true, sectionauthor and moduleauthor directives will be shown in the 102 | # output. They are ignored by default. 103 | #show_authors = False 104 | 105 | # The name of the Pygments (syntax highlighting) style to use. 106 | pygments_style = 'sphinx' 107 | 108 | # A list of ignored prefixes for module index sorting. 109 | #modindex_common_prefix = [] 110 | 111 | # If true, keep warnings as "system message" paragraphs in the built 112 | # documents. 113 | #keep_warnings = False 114 | 115 | 116 | # -- Options for HTML output ------------------------------------------- 117 | 118 | # Add and use Pylons theme 119 | sys.path.append(os.path.abspath('_themes')) 120 | import pylons_sphinx_themes 121 | html_theme_path = pylons_sphinx_themes.get_html_themes_path() 122 | html_theme = 'pylons' 123 | 124 | 125 | html_theme_options = { 126 | 'github_url': 'https://github.com/Pylons/hupper' 127 | } 128 | 129 | # The name for this set of Sphinx documents. If None, it defaults to 130 | # " v documentation". 131 | #html_title = None 132 | 133 | # A shorter title for the navigation bar. Default is the same as 134 | # html_title. 135 | #html_short_title = None 136 | 137 | # The name of an image file (relative to this directory) to place at the 138 | # top of the sidebar. 139 | #html_logo = None 140 | 141 | # The name of an image file (within the static path) to use as favicon 142 | # of the docs. This file should be a Windows icon file (.ico) being 143 | # 16x16 or 32x32 pixels large. 144 | #html_favicon = None 145 | 146 | # Add any paths that contain custom static files (such as style sheets) 147 | # here, relative to this directory. They are copied after the builtin 148 | # static files, so a file named "default.css" will overwrite the builtin 149 | # "default.css". 150 | html_static_path = ['_static'] 151 | 152 | # If not '', a 'Last updated on:' timestamp is inserted at every page 153 | # bottom, using the given strftime format. 154 | #html_last_updated_fmt = '%b %d, %Y' 155 | 156 | # If true, SmartyPants will be used to convert quotes and dashes to 157 | # typographically correct entities. 158 | #html_use_smartypants = True 159 | 160 | # Custom sidebar templates, maps document names to template names. 161 | #html_sidebars = {} 162 | 163 | # Additional templates that should be rendered to pages, maps page names 164 | # to template names. 165 | #html_additional_pages = {} 166 | 167 | # If false, no module index is generated. 168 | #html_domain_indices = True 169 | 170 | # If false, no index is generated. 171 | #html_use_index = True 172 | 173 | # If true, the index is split into individual pages for each letter. 174 | #html_split_index = False 175 | 176 | # If true, links to the reST sources are added to the pages. 177 | #html_show_sourcelink = True 178 | 179 | # If true, "Created using Sphinx" is shown in the HTML footer. 180 | # Default is True. 181 | #html_show_sphinx = True 182 | 183 | # If true, "(C) Copyright ..." is shown in the HTML footer. 184 | # Default is True. 185 | #html_show_copyright = True 186 | 187 | # If true, an OpenSearch description file will be output, and all pages 188 | # will contain a tag referring to it. The value of this option 189 | # must be the base URL from which the finished HTML is served. 190 | #html_use_opensearch = '' 191 | 192 | # This is the file name suffix for HTML files (e.g. ".xhtml"). 193 | #html_file_suffix = None 194 | 195 | # Output file base name for HTML help builder. 196 | htmlhelp_basename = 'hupperdoc' 197 | 198 | 199 | # -- Options for LaTeX output ------------------------------------------ 200 | 201 | latex_elements = { 202 | # The paper size ('letterpaper' or 'a4paper'). 203 | #'papersize': 'letterpaper', 204 | 205 | # The font size ('10pt', '11pt' or '12pt'). 206 | #'pointsize': '10pt', 207 | 208 | # Additional stuff for the LaTeX preamble. 209 | #'preamble': '', 210 | } 211 | 212 | # Grouping the document tree into LaTeX files. List of tuples 213 | # (source start file, target name, title, author, documentclass 214 | # [howto/manual]). 215 | latex_documents = [ 216 | ('index', 'hupper.tex', 217 | u'hupper Documentation', 218 | u'Michael Merickel', 'manual'), 219 | ] 220 | 221 | # The name of an image file (relative to this directory) to place at 222 | # the top of the title page. 223 | #latex_logo = None 224 | 225 | # For "manual" documents, if this is true, then toplevel headings 226 | # are parts, not chapters. 227 | #latex_use_parts = False 228 | 229 | # If true, show page references after internal links. 230 | #latex_show_pagerefs = False 231 | 232 | # If true, show URL addresses after external links. 233 | #latex_show_urls = False 234 | 235 | # Documents to append as an appendix to all manuals. 236 | #latex_appendices = [] 237 | 238 | # If false, no module index is generated. 239 | #latex_domain_indices = True 240 | 241 | 242 | # -- Options for manual page output ------------------------------------ 243 | 244 | # One entry per manual page. List of tuples 245 | # (source start file, name, description, authors, manual section). 246 | man_pages = [ 247 | ('index', 'hupper', 248 | u'hupper Documentation', 249 | [u'Michael Merickel'], 1) 250 | ] 251 | 252 | # If true, show URL addresses after external links. 253 | #man_show_urls = False 254 | 255 | 256 | # -- Options for Texinfo output ---------------------------------------- 257 | 258 | # Grouping the document tree into Texinfo files. List of tuples 259 | # (source start file, target name, title, author, 260 | # dir menu entry, description, category) 261 | texinfo_documents = [ 262 | ('index', 'hupper', 263 | u'hupper Documentation', 264 | u'Michael Merickel', 265 | 'hupper', 266 | 'One line description of project.', 267 | 'Miscellaneous'), 268 | ] 269 | 270 | # Documents to append as an appendix to all manuals. 271 | #texinfo_appendices = [] 272 | 273 | # If false, no module index is generated. 274 | #texinfo_domain_indices = True 275 | 276 | # How to display URL addresses: 'footnote', 'no', or 'inline'. 277 | #texinfo_show_urls = 'footnote' 278 | 279 | # If true, do not generate a @detailmenu in the "Top" node's menu. 280 | #texinfo_no_detailmenu = False 281 | -------------------------------------------------------------------------------- /docs/contributing.rst: -------------------------------------------------------------------------------- 1 | .. include:: ../CONTRIBUTING.rst 2 | -------------------------------------------------------------------------------- /docs/index.rst: -------------------------------------------------------------------------------- 1 | ====== 2 | hupper 3 | ====== 4 | 5 | ``hupper`` is monitor for your Python process. When files change, the process 6 | will be restarted. It can be extended to watch arbitrary files. Reloads can 7 | also be triggered manually from code. 8 | 9 | Builtin file monitors (in order of preference): 10 | 11 | - :ref:`watchman_support` 12 | 13 | - :ref:`watchdog_support` 14 | 15 | - :ref:`polling_support` 16 | 17 | Installation 18 | ============ 19 | 20 | Stable release 21 | -------------- 22 | 23 | To install hupper, run this command in your terminal: 24 | 25 | .. code-block:: console 26 | 27 | $ pip install hupper 28 | 29 | If you don't have `pip`_ installed, this `Python installation guide`_ can guide 30 | you through the process. 31 | 32 | .. _pip: https://pip.pypa.io 33 | .. _Python installation guide: http://docs.python-guide.org/en/latest/starting/installation/ 34 | 35 | 36 | From sources 37 | ------------ 38 | 39 | The sources for hupper can be downloaded from the `Github repo`_. 40 | 41 | .. code-block:: console 42 | 43 | $ git clone https://github.com/Pylons/hupper.git 44 | 45 | Once you have a copy of the source, you can install it with: 46 | 47 | .. code-block:: console 48 | 49 | $ pip install -e . 50 | 51 | .. _Github repo: https://github.com/Pylons/hupper 52 | 53 | Builtin File Monitors 54 | ===================== 55 | 56 | .. _watchman_support: 57 | 58 | Watchman 59 | -------- 60 | 61 | If the `watchman `_ daemon is running, 62 | it is the preferred mechanism for monitoring files. 63 | 64 | On MacOS it can be installed via: 65 | 66 | .. code-block:: console 67 | 68 | $ brew install watchman 69 | 70 | Implementation: :class:`hupper.watchman.WatchmanFileMonitor` 71 | 72 | .. _watchdog_support: 73 | 74 | Watchdog 75 | -------- 76 | 77 | If `watchdog `_ is installed, it will be 78 | used to more efficiently watch for changes to files. 79 | 80 | .. code-block:: console 81 | 82 | $ pip install watchdog 83 | 84 | This is an optional dependency and if it's not installed, then ``hupper`` will 85 | fallback to less efficient polling of the filesystem. 86 | 87 | Implementation: :class:`hupper.watchdog.WatchdogFileMonitor` 88 | 89 | .. _polling_support: 90 | 91 | Polling 92 | ------- 93 | 94 | The least efficient but most portable approach is to use basic file polling. 95 | 96 | The ``reload_interval`` parameter controls how often the filesystem is scanned 97 | and defaults to once per second. 98 | 99 | Implementation: :class:`hupper.polling.PollingFileMonitor` 100 | 101 | Command-line Usage 102 | ================== 103 | 104 | Hupper can load any Python code similar to ``python -m `` by using the 105 | ``hupper -m `` program. 106 | 107 | .. code-block:: console 108 | 109 | $ hupper -m myapp 110 | Starting monitor for PID 23982. 111 | 112 | API Usage 113 | ========= 114 | 115 | The reloading mechanism is implemented by forking worker processes from a 116 | parent monitor. Start by defining an entry point for your process. This must 117 | be an importable path in string format. For example, 118 | ``myapp.scripts.serve.main``: 119 | 120 | .. code-block:: python 121 | 122 | # myapp/scripts/serve.py 123 | 124 | import sys 125 | import hupper 126 | import waitress 127 | 128 | def wsgi_app(environ, start_response): 129 | start_response('200 OK', [('Content-Type', 'text/plain']) 130 | yield [b'hello'] 131 | 132 | def main(args=sys.argv[1:]): 133 | if '--reload' in args: 134 | # start_reloader will only return in a monitored subprocess 135 | reloader = hupper.start_reloader('myapp.scripts.serve.main') 136 | 137 | # monitor an extra file 138 | reloader.watch_files(['foo.ini']) 139 | 140 | waitress.serve(wsgi_app) 141 | 142 | Many applications will tend to re-use the same startup code for both the 143 | monitor and the worker. As a convenience to support this use case, the 144 | :func:`hupper.start_reloader` function can be invoked both from the parent 145 | process as well as the worker. When called initially from the parent process, 146 | it will fork a new worker, then start the monitor and never return. When 147 | called from the worker process it will return a proxy object that can be used 148 | to communicate back to the monitor. 149 | 150 | Checking if the reloader is active 151 | ---------------------------------- 152 | 153 | :func:`hupper.is_active` will return ``True`` if the reloader is active and 154 | the current process may be reloaded. 155 | 156 | Controlling the monitor 157 | ----------------------- 158 | 159 | The worker processes may communicate back to the monitor and notify it of 160 | new files to watch. This can be done by acquiring a reference to the 161 | :class:`hupper.interfaces.IReloaderProxy` instance living in the worker 162 | process. The :func:`hupper.start_reloader` function will return the instance 163 | or :func:`hupper.get_reloader` can be used as well. 164 | 165 | Overriding the default file monitor 166 | ----------------------------------- 167 | 168 | .. versionadded:: 1.2 169 | 170 | By default, ``hupper`` will auto-select the best file monitor based on what 171 | is available. The preferred order is ``watchdog`` then ``polling``. If 172 | ``watchdog`` is installed but you do not want to use it for any reason, you 173 | may override the default by specifying the monitor you wish to use instead in 174 | the ``HUPPER_DEFAULT_MONITOR`` environment variable. For example: 175 | 176 | .. code:: bash 177 | 178 | $ HUPPER_DEFAULT_MONITOR=hupper.polling.PollingFileMonitor hupper -m foo 179 | 180 | More Information 181 | ================ 182 | 183 | .. toctree:: 184 | :maxdepth: 1 185 | 186 | api 187 | contributing 188 | changes 189 | 190 | Indices and tables 191 | ================== 192 | 193 | * :ref:`genindex` 194 | * :ref:`modindex` 195 | * :ref:`search` 196 | -------------------------------------------------------------------------------- /docs/make.bat: -------------------------------------------------------------------------------- 1 | @ECHO OFF 2 | 3 | REM Command file for Sphinx documentation 4 | 5 | if "%SPHINXBUILD%" == "" ( 6 | set SPHINXBUILD=sphinx-build 7 | ) 8 | set BUILDDIR=_build 9 | set ALLSPHINXOPTS=-d %BUILDDIR%/doctrees %SPHINXOPTS% . 10 | set I18NSPHINXOPTS=%SPHINXOPTS% . 11 | if NOT "%PAPER%" == "" ( 12 | set ALLSPHINXOPTS=-D latex_paper_size=%PAPER% %ALLSPHINXOPTS% 13 | set I18NSPHINXOPTS=-D latex_paper_size=%PAPER% %I18NSPHINXOPTS% 14 | ) 15 | 16 | if "%1" == "" goto help 17 | 18 | if "%1" == "help" ( 19 | :help 20 | echo.Please use `make ^` where ^ is one of 21 | echo. html to make standalone HTML files 22 | echo. dirhtml to make HTML files named index.html in directories 23 | echo. singlehtml to make a single large HTML file 24 | echo. pickle to make pickle files 25 | echo. json to make JSON files 26 | echo. htmlhelp to make HTML files and a HTML help project 27 | echo. qthelp to make HTML files and a qthelp project 28 | echo. devhelp to make HTML files and a Devhelp project 29 | echo. epub to make an epub 30 | echo. latex to make LaTeX files, you can set PAPER=a4 or PAPER=letter 31 | echo. text to make text files 32 | echo. man to make manual pages 33 | echo. texinfo to make Texinfo files 34 | echo. gettext to make PO message catalogs 35 | echo. changes to make an overview over all changed/added/deprecated items 36 | echo. xml to make Docutils-native XML files 37 | echo. pseudoxml to make pseudoxml-XML files for display purposes 38 | echo. linkcheck to check all external links for integrity 39 | echo. doctest to run all doctests embedded in the documentation if enabled 40 | goto end 41 | ) 42 | 43 | if "%1" == "clean" ( 44 | for /d %%i in (%BUILDDIR%\*) do rmdir /q /s %%i 45 | del /q /s %BUILDDIR%\* 46 | goto end 47 | ) 48 | 49 | 50 | %SPHINXBUILD% 2> nul 51 | if errorlevel 9009 ( 52 | echo. 53 | echo.The 'sphinx-build' command was not found. Make sure you have Sphinx 54 | echo.installed, then set the SPHINXBUILD environment variable to point 55 | echo.to the full path of the 'sphinx-build' executable. Alternatively you 56 | echo.may add the Sphinx directory to PATH. 57 | echo. 58 | echo.If you don't have Sphinx installed, grab it from 59 | echo.http://sphinx-doc.org/ 60 | exit /b 1 61 | ) 62 | 63 | if "%1" == "html" ( 64 | %SPHINXBUILD% -b html %ALLSPHINXOPTS% %BUILDDIR%/html 65 | if errorlevel 1 exit /b 1 66 | echo. 67 | echo.Build finished. The HTML pages are in %BUILDDIR%/html. 68 | goto end 69 | ) 70 | 71 | if "%1" == "dirhtml" ( 72 | %SPHINXBUILD% -b dirhtml %ALLSPHINXOPTS% %BUILDDIR%/dirhtml 73 | if errorlevel 1 exit /b 1 74 | echo. 75 | echo.Build finished. The HTML pages are in %BUILDDIR%/dirhtml. 76 | goto end 77 | ) 78 | 79 | if "%1" == "singlehtml" ( 80 | %SPHINXBUILD% -b singlehtml %ALLSPHINXOPTS% %BUILDDIR%/singlehtml 81 | if errorlevel 1 exit /b 1 82 | echo. 83 | echo.Build finished. The HTML pages are in %BUILDDIR%/singlehtml. 84 | goto end 85 | ) 86 | 87 | if "%1" == "pickle" ( 88 | %SPHINXBUILD% -b pickle %ALLSPHINXOPTS% %BUILDDIR%/pickle 89 | if errorlevel 1 exit /b 1 90 | echo. 91 | echo.Build finished; now you can process the pickle files. 92 | goto end 93 | ) 94 | 95 | if "%1" == "json" ( 96 | %SPHINXBUILD% -b json %ALLSPHINXOPTS% %BUILDDIR%/json 97 | if errorlevel 1 exit /b 1 98 | echo. 99 | echo.Build finished; now you can process the JSON files. 100 | goto end 101 | ) 102 | 103 | if "%1" == "htmlhelp" ( 104 | %SPHINXBUILD% -b htmlhelp %ALLSPHINXOPTS% %BUILDDIR%/htmlhelp 105 | if errorlevel 1 exit /b 1 106 | echo. 107 | echo.Build finished; now you can run HTML Help Workshop with the ^ 108 | .hhp project file in %BUILDDIR%/htmlhelp. 109 | goto end 110 | ) 111 | 112 | if "%1" == "qthelp" ( 113 | %SPHINXBUILD% -b qthelp %ALLSPHINXOPTS% %BUILDDIR%/qthelp 114 | if errorlevel 1 exit /b 1 115 | echo. 116 | echo.Build finished; now you can run "qcollectiongenerator" with the ^ 117 | .qhcp project file in %BUILDDIR%/qthelp, like this: 118 | echo.^> qcollectiongenerator %BUILDDIR%\qthelp\hupper.qhcp 119 | echo.To view the help file: 120 | echo.^> assistant -collectionFile %BUILDDIR%\qthelp\hupper.ghc 121 | goto end 122 | ) 123 | 124 | if "%1" == "devhelp" ( 125 | %SPHINXBUILD% -b devhelp %ALLSPHINXOPTS% %BUILDDIR%/devhelp 126 | if errorlevel 1 exit /b 1 127 | echo. 128 | echo.Build finished. 129 | goto end 130 | ) 131 | 132 | if "%1" == "epub" ( 133 | %SPHINXBUILD% -b epub %ALLSPHINXOPTS% %BUILDDIR%/epub 134 | if errorlevel 1 exit /b 1 135 | echo. 136 | echo.Build finished. The epub file is in %BUILDDIR%/epub. 137 | goto end 138 | ) 139 | 140 | if "%1" == "latex" ( 141 | %SPHINXBUILD% -b latex %ALLSPHINXOPTS% %BUILDDIR%/latex 142 | if errorlevel 1 exit /b 1 143 | echo. 144 | echo.Build finished; the LaTeX files are in %BUILDDIR%/latex. 145 | goto end 146 | ) 147 | 148 | if "%1" == "latexpdf" ( 149 | %SPHINXBUILD% -b latex %ALLSPHINXOPTS% %BUILDDIR%/latex 150 | cd %BUILDDIR%/latex 151 | make all-pdf 152 | cd %BUILDDIR%/.. 153 | echo. 154 | echo.Build finished; the PDF files are in %BUILDDIR%/latex. 155 | goto end 156 | ) 157 | 158 | if "%1" == "latexpdfja" ( 159 | %SPHINXBUILD% -b latex %ALLSPHINXOPTS% %BUILDDIR%/latex 160 | cd %BUILDDIR%/latex 161 | make all-pdf-ja 162 | cd %BUILDDIR%/.. 163 | echo. 164 | echo.Build finished; the PDF files are in %BUILDDIR%/latex. 165 | goto end 166 | ) 167 | 168 | if "%1" == "text" ( 169 | %SPHINXBUILD% -b text %ALLSPHINXOPTS% %BUILDDIR%/text 170 | if errorlevel 1 exit /b 1 171 | echo. 172 | echo.Build finished. The text files are in %BUILDDIR%/text. 173 | goto end 174 | ) 175 | 176 | if "%1" == "man" ( 177 | %SPHINXBUILD% -b man %ALLSPHINXOPTS% %BUILDDIR%/man 178 | if errorlevel 1 exit /b 1 179 | echo. 180 | echo.Build finished. The manual pages are in %BUILDDIR%/man. 181 | goto end 182 | ) 183 | 184 | if "%1" == "texinfo" ( 185 | %SPHINXBUILD% -b texinfo %ALLSPHINXOPTS% %BUILDDIR%/texinfo 186 | if errorlevel 1 exit /b 1 187 | echo. 188 | echo.Build finished. The Texinfo files are in %BUILDDIR%/texinfo. 189 | goto end 190 | ) 191 | 192 | if "%1" == "gettext" ( 193 | %SPHINXBUILD% -b gettext %I18NSPHINXOPTS% %BUILDDIR%/locale 194 | if errorlevel 1 exit /b 1 195 | echo. 196 | echo.Build finished. The message catalogs are in %BUILDDIR%/locale. 197 | goto end 198 | ) 199 | 200 | if "%1" == "changes" ( 201 | %SPHINXBUILD% -b changes %ALLSPHINXOPTS% %BUILDDIR%/changes 202 | if errorlevel 1 exit /b 1 203 | echo. 204 | echo.The overview file is in %BUILDDIR%/changes. 205 | goto end 206 | ) 207 | 208 | if "%1" == "linkcheck" ( 209 | %SPHINXBUILD% -b linkcheck %ALLSPHINXOPTS% %BUILDDIR%/linkcheck 210 | if errorlevel 1 exit /b 1 211 | echo. 212 | echo.Link check complete; look for any errors in the above output ^ 213 | or in %BUILDDIR%/linkcheck/output.txt. 214 | goto end 215 | ) 216 | 217 | if "%1" == "doctest" ( 218 | %SPHINXBUILD% -b doctest %ALLSPHINXOPTS% %BUILDDIR%/doctest 219 | if errorlevel 1 exit /b 1 220 | echo. 221 | echo.Testing of doctests in the sources finished, look at the ^ 222 | results in %BUILDDIR%/doctest/output.txt. 223 | goto end 224 | ) 225 | 226 | if "%1" == "xml" ( 227 | %SPHINXBUILD% -b xml %ALLSPHINXOPTS% %BUILDDIR%/xml 228 | if errorlevel 1 exit /b 1 229 | echo. 230 | echo.Build finished. The XML files are in %BUILDDIR%/xml. 231 | goto end 232 | ) 233 | 234 | if "%1" == "pseudoxml" ( 235 | %SPHINXBUILD% -b pseudoxml %ALLSPHINXOPTS% %BUILDDIR%/pseudoxml 236 | if errorlevel 1 exit /b 1 237 | echo. 238 | echo.Build finished. The pseudo-XML files are in %BUILDDIR%/pseudoxml. 239 | goto end 240 | ) 241 | 242 | :end 243 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = ["setuptools>=41.0.1", "wheel"] 3 | build-backend = "setuptools.build_meta" 4 | 5 | [tool.black] 6 | line-length = 79 7 | skip-string-normalization = true 8 | target_version = ["py37", "py38", "py39", "py310", "py311"] 9 | exclude = ''' 10 | /( 11 | \.git 12 | | \.mypy_cache 13 | | \.tox 14 | | \.venv 15 | | \.pytest_cache 16 | | dist 17 | | build 18 | | docs 19 | )/ 20 | ''' 21 | 22 | # This next section only exists for people that have their editors 23 | # automatically call isort, black already sorts entries on its own when run. 24 | [tool.isort] 25 | profile = "black" 26 | py_version = 3 27 | combine_as_imports = true 28 | line_length = 79 29 | force_sort_within_sections = true 30 | no_lines_before = "THIRDPARTY" 31 | sections = "FUTURE,THIRDPARTY,FIRSTPARTY,LOCALFOLDER" 32 | default_section = "THIRDPARTY" 33 | known_first_party = "hupper" 34 | -------------------------------------------------------------------------------- /pytest.ini: -------------------------------------------------------------------------------- 1 | [pytest] 2 | python_files = test_*.py 3 | testpaths = 4 | src/hupper 5 | tests 6 | filterwarnings = error 7 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [metadata] 2 | name = hupper 3 | version = 1.12.1 4 | author = Michael Merickel 5 | author_email = pylons-discuss@googlegroups.com 6 | license = MIT 7 | license_files = LICENSE.txt 8 | description = Integrated process monitor for developing and reloading daemons. 9 | long_description = file:README.rst 10 | long_description_content_type = text/x-rst 11 | keywords = 12 | server 13 | daemon 14 | autoreload 15 | reloader 16 | hup 17 | file 18 | watch 19 | process 20 | url = https://github.com/Pylons/hupper 21 | project_urls = 22 | Documentation = https://docs.pylonsproject.org/projects/hupper/en/latest/ 23 | Changelog = https://docs.pylonsproject.org/projects/hupper/en/latest/changes.html 24 | Issue Tracker = https://github.com/Pylons/hupper/issues 25 | classifiers = 26 | Development Status :: 5 - Production/Stable 27 | Intended Audience :: Developers 28 | License :: OSI Approved :: MIT License 29 | Natural Language :: English 30 | Programming Language :: Python :: 3 31 | Programming Language :: Python :: 3.7 32 | Programming Language :: Python :: 3.8 33 | Programming Language :: Python :: 3.9 34 | Programming Language :: Python :: 3.10 35 | Programming Language :: Python :: 3.11 36 | Programming Language :: Python :: 3.12 37 | Programming Language :: Python :: Implementation :: CPython 38 | Programming Language :: Python :: Implementation :: PyPy 39 | 40 | [options] 41 | package_dir = 42 | = src 43 | packages = find: 44 | zip_safe = False 45 | include_package_data = True 46 | python_requires = >=3.7 47 | 48 | [options.packages.find] 49 | where = src 50 | 51 | [options.entry_points] 52 | console_scripts = 53 | hupper = hupper.cli:main 54 | 55 | [options.extras_require] 56 | docs = 57 | watchdog 58 | # need pkg_resources in docs/conf.py until we drop py37 59 | setuptools 60 | Sphinx 61 | pylons-sphinx-themes 62 | testing = 63 | watchdog 64 | pytest 65 | pytest-cov 66 | mock 67 | 68 | [check-manifest] 69 | ignore-default-rules = true 70 | ignore = 71 | .gitignore 72 | PKG-INFO 73 | *.egg-info 74 | *.egg-info/* 75 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | from setuptools import setup 2 | 3 | setup() 4 | -------------------------------------------------------------------------------- /src/hupper/__init__.py: -------------------------------------------------------------------------------- 1 | # public api 2 | # flake8: noqa 3 | 4 | from .reloader import start_reloader 5 | from .utils import is_watchdog_supported, is_watchman_supported 6 | from .worker import get_reloader, is_active 7 | -------------------------------------------------------------------------------- /src/hupper/cli.py: -------------------------------------------------------------------------------- 1 | import argparse 2 | import runpy 3 | import sys 4 | 5 | from .logger import LogLevel 6 | from .reloader import start_reloader 7 | 8 | 9 | def interval_parser(string): 10 | """Parses the shutdown or reload interval into an int greater than 0.""" 11 | msg = "Interval must be an int greater than 0" 12 | try: 13 | value = int(string) 14 | if value <= 0: 15 | raise argparse.ArgumentTypeError(msg) 16 | return value 17 | except ValueError: 18 | raise argparse.ArgumentTypeError(msg) 19 | 20 | 21 | def main(): 22 | parser = argparse.ArgumentParser() 23 | parser.add_argument("-m", dest="module", required=True) 24 | parser.add_argument("-w", dest="watch", action="append") 25 | parser.add_argument("-x", dest="ignore", action="append") 26 | parser.add_argument("-v", dest="verbose", action='store_true') 27 | parser.add_argument("-q", dest="quiet", action='store_true') 28 | parser.add_argument("--shutdown-interval", type=interval_parser) 29 | parser.add_argument("--reload-interval", type=interval_parser) 30 | 31 | args, unknown_args = parser.parse_known_args() 32 | 33 | if args.quiet: 34 | level = LogLevel.ERROR 35 | 36 | elif args.verbose: 37 | level = LogLevel.DEBUG 38 | 39 | else: 40 | level = LogLevel.INFO 41 | 42 | # start_reloader has defaults for some values so we avoid passing 43 | # arguments if we don't have to 44 | reloader_kw = {} 45 | if args.reload_interval is not None: 46 | reloader_kw['reload_interval'] = args.reload_interval 47 | if args.shutdown_interval is not None: 48 | reloader_kw['shutdown_interval'] = args.shutdown_interval 49 | 50 | reloader = start_reloader( 51 | "hupper.cli.main", 52 | verbose=level, 53 | ignore_files=args.ignore, 54 | **reloader_kw, 55 | ) 56 | 57 | sys.argv[1:] = unknown_args 58 | sys.path.insert(0, "") 59 | 60 | if args.watch: 61 | reloader.watch_files(args.watch) 62 | 63 | return runpy.run_module(args.module, alter_sys=True, run_name="__main__") 64 | -------------------------------------------------------------------------------- /src/hupper/interfaces.py: -------------------------------------------------------------------------------- 1 | from abc import ABC, abstractmethod 2 | 3 | 4 | class IReloaderProxy(ABC): 5 | @abstractmethod 6 | def watch_files(self, files): 7 | """Signal to the monitor to track some custom paths.""" 8 | 9 | @abstractmethod 10 | def trigger_reload(self): 11 | """Signal the monitor to execute a reload.""" 12 | 13 | @abstractmethod 14 | def graceful_shutdown(self): 15 | """Signal the monitor to gracefully shutdown.""" 16 | 17 | 18 | class IFileMonitorFactory(ABC): 19 | @abstractmethod 20 | def __call__(self, callback, **kw): 21 | """Return an :class:`.IFileMonitor` instance. 22 | 23 | ``callback`` is a callable to be invoked by the ``IFileMonitor`` 24 | when file changes are detected. It should accept the path of 25 | the changed file as its only parameter. 26 | 27 | Extra keyword-only arguments: 28 | 29 | ``interval`` is the value of ``reload_interval`` passed to the 30 | reloader and may be used to control behavior in the file monitor. 31 | 32 | ``logger`` is an :class:`.ILogger` instance used to record runtime 33 | output. 34 | 35 | """ 36 | 37 | 38 | class IFileMonitor(ABC): 39 | @abstractmethod 40 | def add_path(self, path): 41 | """Start monitoring a new path.""" 42 | 43 | @abstractmethod 44 | def start(self): 45 | """Start the monitor. This method should not block.""" 46 | 47 | @abstractmethod 48 | def stop(self): 49 | """Trigger the monitor to stop. 50 | 51 | This should be called before invoking ``join``. 52 | 53 | """ 54 | 55 | @abstractmethod 56 | def join(self): 57 | """Block until the monitor has stopped.""" 58 | 59 | 60 | class ILogger(ABC): 61 | @abstractmethod 62 | def error(self, msg): 63 | """Record an error message.""" 64 | 65 | @abstractmethod 66 | def info(self, msg): 67 | """Record an informational message.""" 68 | 69 | @abstractmethod 70 | def debug(self, msg): 71 | """Record a debug-only message.""" 72 | -------------------------------------------------------------------------------- /src/hupper/ipc.py: -------------------------------------------------------------------------------- 1 | import errno 2 | import io 3 | import os 4 | import pickle 5 | import struct 6 | import subprocess 7 | import sys 8 | import threading 9 | 10 | from .utils import WIN, is_stream_interactive, resolve_spec 11 | 12 | if WIN: # pragma: no cover 13 | import msvcrt 14 | 15 | from . import winapi 16 | 17 | class ProcessGroup: 18 | def __init__(self): 19 | self.h_job = winapi.CreateJobObject(None, None) 20 | 21 | info = winapi.JOBOBJECT_BASIC_LIMIT_INFORMATION() 22 | info.LimitFlags = winapi.JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE 23 | 24 | extended_info = winapi.JOBOBJECT_EXTENDED_LIMIT_INFORMATION() 25 | extended_info.BasicLimitInformation = info 26 | 27 | winapi.SetInformationJobObject( 28 | self.h_job, 29 | winapi.JobObjectExtendedLimitInformation, 30 | extended_info, 31 | ) 32 | 33 | def add_child(self, pid): 34 | hp = winapi.OpenProcess(winapi.PROCESS_ALL_ACCESS, False, pid) 35 | try: 36 | return winapi.AssignProcessToJobObject(self.h_job, hp) 37 | except OSError as ex: 38 | if getattr(ex, 'winerror', None) == 5: 39 | # skip ACCESS_DENIED_ERROR on windows < 8 which occurs when 40 | # the process is already attached to another job 41 | pass 42 | else: 43 | raise 44 | 45 | def snapshot_termios(stream): 46 | pass 47 | 48 | def restore_termios(stream, state): 49 | pass 50 | 51 | def get_handle(fd): 52 | return msvcrt.get_osfhandle(fd) 53 | 54 | def open_handle(handle, mode): 55 | flags = 0 56 | if 'w' not in mode and '+' not in mode: 57 | flags |= os.O_RDONLY 58 | if 'b' not in mode: 59 | flags |= os.O_TEXT 60 | if 'a' in mode: 61 | flags |= os.O_APPEND 62 | return msvcrt.open_osfhandle(handle, flags) 63 | 64 | else: 65 | import fcntl 66 | import termios 67 | 68 | class ProcessGroup: 69 | def add_child(self, pid): 70 | # nothing to do on *nix 71 | pass 72 | 73 | def snapshot_termios(stream): 74 | if is_stream_interactive(stream): 75 | state = termios.tcgetattr(stream.fileno()) 76 | return state 77 | 78 | def restore_termios(stream, state): 79 | if state and is_stream_interactive(stream): 80 | fd = stream.fileno() 81 | termios.tcflush(fd, termios.TCIOFLUSH) 82 | termios.tcsetattr(fd, termios.TCSANOW, state) 83 | 84 | def get_handle(fd): 85 | return fd 86 | 87 | def open_handle(handle, mode): 88 | return handle 89 | 90 | 91 | def _pipe(): 92 | r, w = os.pipe() 93 | set_inheritable(r, False) 94 | set_inheritable(w, False) 95 | return r, w 96 | 97 | 98 | def Pipe(): 99 | c2pr_fd, c2pw_fd = _pipe() 100 | p2cr_fd, p2cw_fd = _pipe() 101 | 102 | c1 = Connection(c2pr_fd, p2cw_fd) 103 | c2 = Connection(p2cr_fd, c2pw_fd) 104 | return c1, c2 105 | 106 | 107 | class Connection: 108 | """ 109 | A connection to a bi-directional pipe. 110 | 111 | """ 112 | 113 | _packet_len = struct.Struct('Q') 114 | 115 | send_lock = None 116 | reader_thread = None 117 | on_recv = lambda _: None 118 | 119 | def __init__(self, r_fd, w_fd): 120 | self.r_fd = r_fd 121 | self.w_fd = w_fd 122 | 123 | def __getstate__(self): 124 | return { 125 | 'r_handle': get_handle(self.r_fd), 126 | 'w_handle': get_handle(self.w_fd), 127 | } 128 | 129 | def __setstate__(self, state): 130 | self.r_fd = open_handle(state['r_handle'], 'rb') 131 | self.w_fd = open_handle(state['w_handle'], 'wb') 132 | 133 | def activate(self, on_recv): 134 | self.on_recv = on_recv 135 | 136 | self.send_lock = threading.Lock() 137 | 138 | self.reader_thread = threading.Thread(target=self._read_loop) 139 | self.reader_thread.daemon = True 140 | self.reader_thread.start() 141 | 142 | def close(self): 143 | self.on_recv = lambda _: None 144 | self.r_fd, r_fd = -1, self.r_fd 145 | self.w_fd, w_fd = -1, self.w_fd 146 | 147 | close_fd(w_fd) 148 | close_fd(r_fd) 149 | if self.reader_thread: 150 | self.reader_thread.join() 151 | 152 | def _recv_packet(self): 153 | buf = io.BytesIO() 154 | chunk = os.read(self.r_fd, self._packet_len.size) 155 | if not chunk: 156 | return 157 | size = remaining = self._packet_len.unpack(chunk)[0] 158 | while remaining > 0: 159 | chunk = os.read(self.r_fd, remaining) 160 | n = len(chunk) 161 | if n == 0: 162 | if remaining == size: 163 | raise EOFError 164 | else: 165 | raise IOError('got end of file during message') 166 | buf.write(chunk) 167 | remaining -= n 168 | return pickle.loads(buf.getvalue()) 169 | 170 | def _read_loop(self): 171 | try: 172 | while True: 173 | packet = self._recv_packet() 174 | if packet is None: 175 | break 176 | self.on_recv(packet) 177 | except EOFError: 178 | pass 179 | except OSError as e: 180 | if e.errno != errno.EBADF: 181 | raise 182 | self.on_recv(None) 183 | 184 | def _write_packet(self, data): 185 | while data: 186 | n = os.write(self.w_fd, data) 187 | data = data[n:] 188 | 189 | def send(self, value): 190 | data = pickle.dumps(value) 191 | with self.send_lock: 192 | self._write_packet(self._packet_len.pack(len(data))) 193 | self._write_packet(data) 194 | return len(data) + self._packet_len.size 195 | 196 | 197 | def set_inheritable(fd, inheritable): 198 | # On py34+ we can use os.set_inheritable but < py34 we must polyfill 199 | # with fcntl and SetHandleInformation 200 | if hasattr(os, 'get_inheritable'): 201 | if os.get_inheritable(fd) != inheritable: 202 | os.set_inheritable(fd, inheritable) 203 | 204 | elif WIN: 205 | h = get_handle(fd) 206 | flags = winapi.HANDLE_FLAG_INHERIT if inheritable else 0 207 | winapi.SetHandleInformation(h, winapi.HANDLE_FLAG_INHERIT, flags) 208 | 209 | else: 210 | flags = fcntl.fcntl(fd, fcntl.F_GETFD) 211 | if inheritable: 212 | new_flags = flags & ~fcntl.FD_CLOEXEC 213 | else: 214 | new_flags = flags | fcntl.FD_CLOEXEC 215 | if new_flags != flags: 216 | fcntl.fcntl(fd, fcntl.F_SETFD, new_flags) 217 | 218 | 219 | def close_fd(fd, raises=True): 220 | if fd is not None: 221 | try: 222 | os.close(fd) 223 | except Exception: # pragma: no cover 224 | if raises: 225 | raise 226 | 227 | 228 | def args_from_interpreter_flags(): 229 | """ 230 | Return a list of command-line arguments reproducing the current 231 | settings in sys.flags and sys.warnoptions. 232 | 233 | """ 234 | flag_opt_map = { 235 | 'debug': 'd', 236 | 'dont_write_bytecode': 'B', 237 | 'no_user_site': 's', 238 | 'no_site': 'S', 239 | 'ignore_environment': 'E', 240 | 'verbose': 'v', 241 | 'bytes_warning': 'b', 242 | 'quiet': 'q', 243 | 'optimize': 'O', 244 | } 245 | args = [] 246 | for flag, opt in flag_opt_map.items(): 247 | v = getattr(sys.flags, flag, 0) 248 | if v > 0: 249 | args.append('-' + opt * v) 250 | for opt in sys.warnoptions: 251 | args.append('-W' + opt) 252 | return args 253 | 254 | 255 | def get_command_line(**kwds): 256 | prog = 'from hupper.ipc import spawn_main; spawn_main(%s)' 257 | prog %= ', '.join('%s=%r' % item for item in kwds.items()) 258 | opts = args_from_interpreter_flags() 259 | args = [sys.executable] + opts + ['-c', prog] 260 | 261 | # ensure hupper is on the PYTHONPATH in the worker process 262 | # 263 | # there are some cases where hupper may only be importable because of 264 | # direct manipulation of sys.path (zc.buildout) which is not reflected 265 | # into the subprocess without us doing it manually 266 | # see https://github.com/Pylons/hupper/issues/25 267 | hupper_root = os.path.dirname( 268 | os.path.dirname(os.path.abspath(os.path.join(__file__))) 269 | ) 270 | extra_py_paths = [hupper_root] 271 | 272 | env = os.environ.copy() 273 | env['PYTHONPATH'] = ( 274 | os.pathsep.join(extra_py_paths) 275 | + os.pathsep 276 | + env.get('PYTHONPATH', '') 277 | ) 278 | return args, env 279 | 280 | 281 | def get_preparation_data(): 282 | data = {} 283 | data['sys.argv'] = sys.argv 284 | 285 | # multiprocessing does some work here to replace '' in sys.path with 286 | # os.getcwd() but it is not valid to assume that os.getcwd() at the time 287 | # hupper is imported is the starting folder of the process so for now 288 | # we'll just assume that the user has not changed the CWD 289 | data['sys.path'] = list(sys.path) 290 | return data 291 | 292 | 293 | def prepare(data): 294 | if 'sys.argv' in data: 295 | sys.argv = data['sys.argv'] 296 | 297 | if 'sys.path' in data: 298 | sys.path = data['sys.path'] 299 | 300 | 301 | def spawn(spec, kwargs, pass_fds=()): 302 | """ 303 | Invoke a python function in a subprocess. 304 | 305 | """ 306 | r, w = os.pipe() 307 | for fd in [r] + list(pass_fds): 308 | set_inheritable(fd, True) 309 | 310 | preparation_data = get_preparation_data() 311 | 312 | r_handle = get_handle(r) 313 | args, env = get_command_line(pipe_handle=r_handle) 314 | process = subprocess.Popen(args, env=env, close_fds=False) 315 | 316 | to_child = os.fdopen(w, 'wb') 317 | to_child.write(pickle.dumps([preparation_data, spec, kwargs])) 318 | to_child.close() 319 | 320 | return process 321 | 322 | 323 | def spawn_main(pipe_handle): 324 | fd = open_handle(pipe_handle, 'rb') 325 | from_parent = os.fdopen(fd, 'rb') 326 | preparation_data, spec, kwargs = pickle.load(from_parent) 327 | from_parent.close() 328 | 329 | prepare(preparation_data) 330 | 331 | func = resolve_spec(spec) 332 | func(**kwargs) 333 | sys.exit(0) 334 | 335 | 336 | def wait(process, timeout=None): 337 | if timeout is None: 338 | return process.wait() 339 | 340 | if timeout == 0: 341 | return process.poll() 342 | 343 | try: 344 | return process.wait(timeout) 345 | except subprocess.TimeoutExpired: 346 | pass 347 | 348 | 349 | def kill(process, soft=False): 350 | if soft: 351 | return process.terminate() 352 | return process.kill() 353 | -------------------------------------------------------------------------------- /src/hupper/logger.py: -------------------------------------------------------------------------------- 1 | import sys 2 | 3 | from .interfaces import ILogger 4 | 5 | 6 | class LogLevel: 7 | ERROR = 0 8 | INFO = 1 9 | DEBUG = 2 10 | 11 | 12 | class DefaultLogger(ILogger): 13 | def __init__(self, level): 14 | self.level = level 15 | 16 | def _out(self, level, msg): 17 | if level <= self.level: 18 | print(msg, file=sys.stderr) 19 | 20 | def error(self, msg): 21 | self._out(LogLevel.ERROR, '[ERROR] ' + msg) 22 | 23 | def info(self, msg): 24 | self._out(LogLevel.INFO, msg) 25 | 26 | def debug(self, msg): 27 | self._out(LogLevel.DEBUG, '[DEBUG] ' + msg) 28 | 29 | 30 | class SilentLogger(ILogger): 31 | def error(self, msg): 32 | pass 33 | 34 | def info(self, msg): 35 | pass 36 | 37 | def debug(self, msg): 38 | pass 39 | -------------------------------------------------------------------------------- /src/hupper/polling.py: -------------------------------------------------------------------------------- 1 | import os 2 | import threading 3 | import time 4 | 5 | from .interfaces import IFileMonitor 6 | 7 | 8 | class PollingFileMonitor(threading.Thread, IFileMonitor): 9 | """ 10 | An :class:`hupper.interfaces.IFileMonitor` that stats the files 11 | at periodic intervals. 12 | 13 | ``callback`` is a callable that accepts a path to a changed file. 14 | 15 | ``interval`` is a value in seconds between scans of the files on disk. 16 | Do not set this too low or it will eat your CPU and kill your drive. 17 | 18 | """ 19 | 20 | def __init__(self, callback, interval=1, **kw): 21 | super(PollingFileMonitor, self).__init__() 22 | self.callback = callback 23 | self.poll_interval = interval 24 | self.paths = set() 25 | self.mtimes = {} 26 | self.lock = threading.Lock() 27 | self.enabled = True 28 | 29 | def add_path(self, path): 30 | with self.lock: 31 | self.paths.add(path) 32 | 33 | def run(self): 34 | while self.enabled: 35 | with self.lock: 36 | paths = list(self.paths) 37 | self.check_reload(paths) 38 | time.sleep(self.poll_interval) 39 | 40 | def stop(self): 41 | self.enabled = False 42 | 43 | def check_reload(self, paths): 44 | changes = set() 45 | for path in paths: 46 | mtime = get_mtime(path) 47 | if path not in self.mtimes: 48 | self.mtimes[path] = mtime 49 | elif self.mtimes[path] < mtime: 50 | self.mtimes[path] = mtime 51 | changes.add(path) 52 | for path in sorted(changes): 53 | self.callback(path) 54 | 55 | 56 | def get_mtime(path): 57 | try: 58 | stat = os.stat(path) 59 | if stat: 60 | return stat.st_mtime 61 | except OSError: # pragma: no cover 62 | pass 63 | return 0 64 | -------------------------------------------------------------------------------- /src/hupper/reloader.py: -------------------------------------------------------------------------------- 1 | from collections import deque 2 | from contextlib import contextmanager 3 | import fnmatch 4 | from glob import glob 5 | import os 6 | import re 7 | import signal 8 | import sys 9 | import threading 10 | import time 11 | 12 | from .ipc import ProcessGroup, close_fd 13 | from .logger import DefaultLogger, SilentLogger 14 | from .utils import ( 15 | WIN, 16 | default, 17 | is_stream_interactive, 18 | is_watchdog_supported, 19 | is_watchman_supported, 20 | resolve_spec, 21 | ) 22 | from .worker import Worker, get_reloader, is_active 23 | 24 | if WIN: 25 | from . import winapi 26 | 27 | 28 | class FileMonitorProxy: 29 | """ 30 | Wrap an :class:`hupper.interfaces.IFileMonitor` into an object that 31 | exposes a thread-safe interface back to the reloader to detect 32 | when it should reload. 33 | 34 | """ 35 | 36 | monitor = None 37 | 38 | def __init__(self, callback, logger, ignore_files=None): 39 | self.callback = callback 40 | self.logger = logger 41 | self.changed_paths = set() 42 | self.ignore_files = [ 43 | re.compile(fnmatch.translate(x)) for x in set(ignore_files or []) 44 | ] 45 | self.lock = threading.Lock() 46 | self.is_changed = False 47 | 48 | def add_path(self, path): 49 | # if the glob does not match any files then go ahead and pass 50 | # the pattern to the monitor anyway incase it is just a file that 51 | # is currently missing 52 | for p in glob(path, recursive=True) or [path]: 53 | if not any(x.match(p) for x in self.ignore_files): 54 | self.monitor.add_path(p) 55 | 56 | def start(self): 57 | self.monitor.start() 58 | 59 | def stop(self): 60 | self.monitor.stop() 61 | self.monitor.join() 62 | 63 | def file_changed(self, path): 64 | with self.lock: 65 | if path not in self.changed_paths: 66 | self.logger.info('{} changed; reloading ...'.format(path)) 67 | self.changed_paths.add(path) 68 | 69 | if not self.is_changed: 70 | self.is_changed = True 71 | self.callback(self.changed_paths) 72 | 73 | def clear_changes(self): 74 | with self.lock: 75 | self.changed_paths = set() 76 | self.is_changed = False 77 | 78 | 79 | class ControlSignal: 80 | byte = lambda x: chr(x).encode('ascii') 81 | 82 | SIGINT = byte(1) 83 | SIGHUP = byte(2) 84 | SIGTERM = byte(3) 85 | SIGCHLD = byte(4) 86 | FILE_CHANGED = byte(10) 87 | WORKER_COMMAND = byte(11) 88 | 89 | del byte 90 | 91 | 92 | class WorkerResult: 93 | # exit - do not reload 94 | EXIT = 'exit' 95 | 96 | # reload immediately 97 | RELOAD = 'reload' 98 | 99 | # wait for changes before reloading 100 | WAIT = 'wait' 101 | 102 | 103 | class Reloader: 104 | """ 105 | A wrapper class around a file monitor which will handle changes by 106 | restarting a new worker process. 107 | 108 | """ 109 | 110 | def __init__( 111 | self, 112 | worker_path, 113 | monitor_factory, 114 | logger, 115 | reload_interval=1, 116 | shutdown_interval=1, 117 | worker_args=None, 118 | worker_kwargs=None, 119 | ignore_files=None, 120 | ): 121 | self.worker_path = worker_path 122 | self.worker_args = worker_args 123 | self.worker_kwargs = worker_kwargs 124 | self.ignore_files = ignore_files 125 | self.monitor_factory = monitor_factory 126 | self.reload_interval = reload_interval 127 | self.shutdown_interval = shutdown_interval 128 | self.logger = logger 129 | self.monitor = None 130 | self.process_group = ProcessGroup() 131 | 132 | def run(self): 133 | """ 134 | Execute the reloader forever, blocking the current thread. 135 | 136 | This will invoke ``sys.exit`` with the return code from the 137 | subprocess. If interrupted before the process starts then 138 | it'll exit with ``-1``. 139 | 140 | """ 141 | exitcode = -1 142 | with self._setup_runtime(): 143 | while True: 144 | result, exitcode = self._run_worker() 145 | if result == WorkerResult.EXIT: 146 | break 147 | start = time.time() 148 | if result == WorkerResult.WAIT: 149 | result, _ = self._wait_for_changes() 150 | if result == WorkerResult.EXIT: 151 | break 152 | dt = self.reload_interval - (time.time() - start) 153 | if dt > 0: 154 | time.sleep(dt) 155 | sys.exit(exitcode) 156 | 157 | def run_once(self): 158 | """ 159 | Execute the worker once. 160 | 161 | This method will return after the worker exits. 162 | 163 | Returns the exit code from the worker process. 164 | 165 | """ 166 | with self._setup_runtime(): 167 | _, exitcode = self._run_worker() 168 | return exitcode 169 | 170 | def _run_worker(self): 171 | worker = Worker( 172 | self.worker_path, args=self.worker_args, kwargs=self.worker_kwargs 173 | ) 174 | return _run_worker(self, worker) 175 | 176 | def _wait_for_changes(self): 177 | worker = Worker(__name__ + '.wait_main') 178 | return _run_worker( 179 | self, 180 | worker, 181 | logger=SilentLogger(), 182 | shutdown_interval=0, 183 | ) 184 | 185 | @contextmanager 186 | def _setup_runtime(self): 187 | with self._start_control(): 188 | with self._start_monitor(): 189 | with self._capture_signals(): 190 | yield 191 | 192 | @contextmanager 193 | def _start_control(self): 194 | self.control_r, self.control_w = os.pipe() 195 | try: 196 | yield 197 | finally: 198 | close_fd(self.control_w) 199 | close_fd(self.control_r) 200 | self.control_r = self.control_w = None 201 | 202 | def _control_proxy(self, signal): 203 | return lambda *args: os.write(self.control_w, signal) 204 | 205 | @contextmanager 206 | def _start_monitor(self): 207 | proxy = FileMonitorProxy( 208 | self._control_proxy(ControlSignal.FILE_CHANGED), 209 | self.logger, 210 | self.ignore_files, 211 | ) 212 | proxy.monitor = self.monitor_factory( 213 | proxy.file_changed, 214 | interval=self.reload_interval, 215 | logger=self.logger, 216 | ) 217 | self.monitor = proxy 218 | self.monitor.start() 219 | try: 220 | yield 221 | finally: 222 | self.monitor = None 223 | proxy.stop() 224 | 225 | _signals = { 226 | 'SIGINT': ControlSignal.SIGINT, 227 | 'SIGHUP': ControlSignal.SIGHUP, 228 | 'SIGTERM': ControlSignal.SIGTERM, 229 | 'SIGCHLD': ControlSignal.SIGCHLD, 230 | } 231 | 232 | @contextmanager 233 | def _capture_signals(self): 234 | undo_handlers = [] 235 | try: 236 | for signame, control in self._signals.items(): 237 | signum = getattr(signal, signame, None) 238 | if signum is None: 239 | self.logger.debug( 240 | 'Skipping unsupported signal={}'.format(signame) 241 | ) 242 | continue 243 | handler = self._control_proxy(control) 244 | if WIN and signame == 'SIGINT': 245 | undo = winapi.AddConsoleCtrlHandler(handler) 246 | undo_handlers.append(undo) 247 | handler = signal.SIG_IGN 248 | psig = signal.signal(signum, handler) 249 | undo_handlers.append( 250 | lambda s=signum, p=psig: signal.signal(s, p) 251 | ) 252 | yield 253 | finally: 254 | for undo in reversed(undo_handlers): 255 | undo() 256 | 257 | 258 | def _run_worker(self, worker, logger=None, shutdown_interval=None): 259 | if logger is None: 260 | logger = self.logger 261 | 262 | if shutdown_interval is None: 263 | shutdown_interval = self.shutdown_interval 264 | 265 | packets = deque() 266 | 267 | def handle_packet(packet): 268 | packets.append(packet) 269 | os.write(self.control_w, ControlSignal.WORKER_COMMAND) 270 | 271 | self.monitor.clear_changes() 272 | 273 | worker.start(handle_packet) 274 | result = WorkerResult.WAIT 275 | soft_kill = True 276 | 277 | logger.info('Starting monitor for PID %s.' % worker.pid) 278 | try: 279 | # register the worker with the process group 280 | self.process_group.add_child(worker.pid) 281 | 282 | while True: 283 | # process all packets before moving on to signals to avoid 284 | # missing any files that need to be watched 285 | if packets: 286 | cmd = packets.popleft() 287 | 288 | if cmd is None: 289 | if worker.is_alive: 290 | # the worker socket has died but the process is still 291 | # alive (somehow) so wait a brief period to see if it 292 | # dies on its own - if it does die then we want to 293 | # treat it as a crash and wait for changes before 294 | # reloading, if it doesn't die then we want to force 295 | # reload the app immediately because it probably 296 | # didn't die due to some file changes 297 | time.sleep(1) 298 | 299 | if worker.is_alive: 300 | logger.info( 301 | 'Worker pipe died unexpectedly, triggering a ' 302 | 'reload.' 303 | ) 304 | result = WorkerResult.RELOAD 305 | break 306 | 307 | os.write(self.control_w, ControlSignal.SIGCHLD) 308 | continue 309 | 310 | logger.debug('Received worker command "{}".'.format(cmd[0])) 311 | if cmd[0] == 'reload': 312 | result = WorkerResult.RELOAD 313 | break 314 | 315 | elif cmd[0] == 'watch_files': 316 | for path in cmd[1]: 317 | self.monitor.add_path(path) 318 | 319 | elif cmd[0] == 'graceful_shutdown': 320 | os.write(self.control_w, ControlSignal.SIGTERM) 321 | 322 | else: # pragma: no cover 323 | raise RuntimeError('received unknown control signal', cmd) 324 | 325 | # done handling the packet, continue to the next one 326 | # do not fall through here because it will block 327 | continue 328 | 329 | signal = os.read(self.control_r, 1) 330 | 331 | if not signal: 332 | logger.error('Control pipe died unexpectedly.') 333 | result = WorkerResult.EXIT 334 | break 335 | 336 | elif signal == ControlSignal.SIGINT: 337 | logger.info('Received SIGINT, waiting for server to exit ...') 338 | result = WorkerResult.EXIT 339 | 340 | # normally a SIGINT is sent automatically to the process 341 | # group and we want to avoid forwarding both a SIGINT and a 342 | # SIGTERM at the same time 343 | # 344 | # in the off chance that the SIGINT is not sent, we'll 345 | # just terminate after waiting shutdown_interval 346 | soft_kill = False 347 | break 348 | 349 | elif signal == ControlSignal.SIGHUP: 350 | logger.info('Received SIGHUP, triggering a reload.') 351 | result = WorkerResult.RELOAD 352 | break 353 | 354 | elif signal == ControlSignal.SIGTERM: 355 | logger.info('Received SIGTERM, triggering a shutdown.') 356 | result = WorkerResult.EXIT 357 | break 358 | 359 | elif signal == ControlSignal.FILE_CHANGED: 360 | if self.monitor.is_changed: 361 | result = WorkerResult.RELOAD 362 | break 363 | 364 | elif signal == ControlSignal.SIGCHLD: 365 | if not worker.is_alive: 366 | break 367 | 368 | if worker.is_alive and shutdown_interval: 369 | if soft_kill: 370 | logger.info('Gracefully killing the server.') 371 | worker.kill(soft=True) 372 | worker.wait(shutdown_interval) 373 | 374 | finally: 375 | if worker.is_alive: 376 | logger.info('Server did not exit, forcefully killing.') 377 | worker.kill() 378 | worker.join() 379 | 380 | else: 381 | worker.join() 382 | logger.debug('Server exited with code %d.' % worker.exitcode) 383 | 384 | return result, worker.exitcode 385 | 386 | 387 | def wait_main(): 388 | try: 389 | reloader = get_reloader() 390 | if is_stream_interactive(sys.stdin): 391 | input('Press ENTER or change a file to reload.\n') 392 | reloader.trigger_reload() 393 | else: 394 | # just block while we wait for a file to change 395 | print('Waiting for a file to change before reload.') 396 | while True: 397 | time.sleep(10) 398 | except KeyboardInterrupt: 399 | pass 400 | 401 | 402 | def find_default_monitor_factory(logger): 403 | spec = os.getenv('HUPPER_DEFAULT_MONITOR') 404 | if spec: 405 | monitor_factory = resolve_spec(spec) 406 | 407 | logger.debug('File monitor backend: ' + spec) 408 | 409 | elif is_watchman_supported(): 410 | from .watchman import WatchmanFileMonitor as monitor_factory 411 | 412 | logger.debug('File monitor backend: watchman') 413 | 414 | elif is_watchdog_supported(): 415 | from .watchdog import WatchdogFileMonitor as monitor_factory 416 | 417 | logger.debug('File monitor backend: watchdog') 418 | 419 | else: 420 | from .polling import PollingFileMonitor as monitor_factory 421 | 422 | logger.debug('File monitor backend: polling') 423 | 424 | return monitor_factory 425 | 426 | 427 | def start_reloader( 428 | worker_path, 429 | reload_interval=1, 430 | shutdown_interval=default, 431 | verbose=1, 432 | logger=None, 433 | monitor_factory=None, 434 | worker_args=None, 435 | worker_kwargs=None, 436 | ignore_files=None, 437 | ): 438 | """ 439 | Start a monitor and then fork a worker process which starts by executing 440 | the importable function at ``worker_path``. 441 | 442 | If this function is called from a worker process that is already being 443 | monitored then it will return a reference to the current 444 | :class:`hupper.interfaces.IReloaderProxy` which can be used to 445 | communicate with the monitor. 446 | 447 | ``worker_path`` must be a dotted string pointing to a globally importable 448 | function that will be executed to start the worker. An example could be 449 | ``myapp.cli.main``. In most cases it will point at the same function that 450 | is invoking ``start_reloader`` in the first place. 451 | 452 | ``reload_interval`` is a value in seconds and will be used to throttle 453 | restarts. Default is ``1``. 454 | 455 | ``shutdown_interval`` is a value in seconds and will be used to trigger 456 | a graceful shutdown of the server. Set to ``None`` to disable the graceful 457 | shutdown. Default is the same as ``reload_interval``. 458 | 459 | ``verbose`` controls the output. Set to ``0`` to turn off any logging 460 | of activity and turn up to ``2`` for extra output. Default is ``1``. 461 | 462 | ``logger``, if supplied, supersedes ``verbose`` and should be an object 463 | implementing :class:`hupper.interfaces.ILogger`. 464 | 465 | ``monitor_factory`` is an instance of 466 | :class:`hupper.interfaces.IFileMonitorFactory`. If left unspecified, this 467 | will try to create a :class:`hupper.watchdog.WatchdogFileMonitor` if 468 | `watchdog `_ is installed and will 469 | fallback to the less efficient 470 | :class:`hupper.polling.PollingFileMonitor` otherwise. 471 | 472 | If ``monitor_factory`` is ``None`` it can be overridden by the 473 | ``HUPPER_DEFAULT_MONITOR`` environment variable. It should be a dotted 474 | python path pointing at an object implementing 475 | :class:`hupper.interfaces.IFileMonitorFactory`. 476 | 477 | ``ignore_files`` if provided must be an iterable of shell-style patterns 478 | to ignore. 479 | """ 480 | if is_active(): 481 | return get_reloader() 482 | 483 | if logger is None: 484 | logger = DefaultLogger(verbose) 485 | 486 | if monitor_factory is None: 487 | monitor_factory = find_default_monitor_factory(logger) 488 | 489 | if shutdown_interval is default: 490 | shutdown_interval = reload_interval 491 | 492 | if reload_interval <= 0: 493 | raise ValueError( 494 | 'reload_interval must be greater than 0 to avoid spinning' 495 | ) 496 | 497 | reloader = Reloader( 498 | worker_path=worker_path, 499 | worker_args=worker_args, 500 | worker_kwargs=worker_kwargs, 501 | reload_interval=reload_interval, 502 | shutdown_interval=shutdown_interval, 503 | monitor_factory=monitor_factory, 504 | logger=logger, 505 | ignore_files=ignore_files, 506 | ) 507 | return reloader.run() 508 | -------------------------------------------------------------------------------- /src/hupper/utils.py: -------------------------------------------------------------------------------- 1 | import importlib 2 | import json 3 | import os 4 | import subprocess 5 | import sys 6 | 7 | WIN = sys.platform == 'win32' 8 | 9 | 10 | class Sentinel: 11 | def __init__(self, name): 12 | self.name = name 13 | 14 | def __repr__(self): 15 | return '<{0}>'.format(self.name) 16 | 17 | 18 | default = Sentinel('default') 19 | 20 | 21 | def resolve_spec(spec): 22 | modname, funcname = spec.rsplit('.', 1) 23 | module = importlib.import_module(modname) 24 | func = getattr(module, funcname) 25 | return func 26 | 27 | 28 | def is_watchdog_supported(): 29 | """Return ``True`` if watchdog is available.""" 30 | try: 31 | import watchdog # noqa: F401 32 | except ImportError: 33 | return False 34 | return True 35 | 36 | 37 | def is_watchman_supported(): 38 | """Return ``True`` if watchman is available.""" 39 | if WIN: 40 | # for now we aren't bothering with windows sockets 41 | return False 42 | 43 | try: 44 | sockpath = get_watchman_sockpath() 45 | return bool(sockpath) 46 | except Exception: 47 | return False 48 | 49 | 50 | def get_watchman_sockpath(binpath='watchman'): 51 | """Find the watchman socket or raise.""" 52 | path = os.getenv('WATCHMAN_SOCK') 53 | if path: 54 | return path 55 | 56 | cmd = [binpath, '--output-encoding=json', 'get-sockname'] 57 | result = subprocess.check_output(cmd) 58 | result = json.loads(result) 59 | return result['sockname'] 60 | 61 | 62 | def is_stream_interactive(stream): 63 | return stream is not None and stream.isatty() 64 | -------------------------------------------------------------------------------- /src/hupper/watchdog.py: -------------------------------------------------------------------------------- 1 | # check ``hupper.utils.is_watchdog_supported`` before using this module 2 | import os.path 3 | import threading 4 | from watchdog.events import FileSystemEventHandler 5 | from watchdog.observers import Observer 6 | 7 | from .interfaces import IFileMonitor 8 | 9 | 10 | class WatchdogFileMonitor(FileSystemEventHandler, Observer, IFileMonitor): 11 | """ 12 | An :class:`hupper.interfaces.IFileMonitor` that uses ``watchdog`` 13 | to watch for file changes uses inotify. 14 | 15 | ``callback`` is a callable that accepts a path to a changed file. 16 | 17 | ``logger`` is an :class:`hupper.interfaces.ILogger` instance. 18 | 19 | """ 20 | 21 | def __init__(self, callback, logger, **kw): 22 | super(WatchdogFileMonitor, self).__init__() 23 | self.callback = callback 24 | self.logger = logger 25 | self.paths = set() 26 | self.dirpaths = set() 27 | self.lock = threading.Lock() 28 | 29 | def add_path(self, path): 30 | with self.lock: 31 | dirpath = os.path.dirname(path) 32 | if dirpath not in self.dirpaths: 33 | try: 34 | self.schedule(self, dirpath) 35 | except OSError as ex: # pragma: no cover 36 | # watchdog raises exceptions if folders are missing 37 | # or if the ulimit is passed 38 | self.logger.error('watchdog error: ' + str(ex)) 39 | else: 40 | self.dirpaths.add(dirpath) 41 | 42 | if path not in self.paths: 43 | self.paths.add(path) 44 | 45 | def _check(self, path): 46 | with self.lock: 47 | if path in self.paths: 48 | self.callback(path) 49 | 50 | def on_created(self, event): 51 | self._check(event.src_path) 52 | 53 | def on_modified(self, event): 54 | self._check(event.src_path) 55 | 56 | def on_moved(self, event): 57 | self._check(event.src_path) 58 | self._check(event.dest_path) 59 | self.add_path(event.dest_path) 60 | 61 | def on_deleted(self, event): 62 | self._check(event.src_path) 63 | -------------------------------------------------------------------------------- /src/hupper/watchman.py: -------------------------------------------------------------------------------- 1 | # check ``hupper.utils.is_watchman_supported`` before using this module 2 | import errno 3 | import json 4 | import os 5 | import queue 6 | import select 7 | import socket 8 | import threading 9 | import time 10 | 11 | from .interfaces import IFileMonitor 12 | from .utils import get_watchman_sockpath 13 | 14 | 15 | class WatchmanFileMonitor(threading.Thread, IFileMonitor): 16 | """ 17 | An :class:`hupper.interfaces.IFileMonitor` that uses Facebook's 18 | ``watchman`` daemon to detect changes. 19 | 20 | ``callback`` is a callable that accepts a path to a changed file. 21 | 22 | """ 23 | 24 | def __init__( 25 | self, 26 | callback, 27 | logger, 28 | sockpath=None, 29 | binpath='watchman', 30 | timeout=10.0, 31 | **kw, 32 | ): 33 | super(WatchmanFileMonitor, self).__init__() 34 | self.callback = callback 35 | self.logger = logger 36 | self.watches = set() 37 | self.paths = set() 38 | self.lock = threading.Lock() 39 | self.enabled = True 40 | self.sockpath = sockpath 41 | self.binpath = binpath 42 | self.timeout = timeout 43 | self.responses = queue.Queue() 44 | 45 | def add_path(self, path): 46 | is_new_root = False 47 | with self.lock: 48 | root = os.path.dirname(path) 49 | for watch in self.watches: 50 | if watch == root or root.startswith(watch + os.sep): 51 | break 52 | else: 53 | is_new_root = True 54 | 55 | if path not in self.paths: 56 | self.paths.add(path) 57 | 58 | # it's important to release the above lock before invoking _watch 59 | # on a new root to prevent deadlocks 60 | if is_new_root: 61 | self._watch(root) 62 | 63 | def start(self): 64 | sockpath = self._resolve_sockpath() 65 | sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) 66 | sock.connect(sockpath) 67 | self._sock = sock 68 | self._recvbufs = [] 69 | 70 | self._send(['version']) 71 | result = self._recv() 72 | self.logger.debug('watchman v' + result['version'] + '.') 73 | 74 | super(WatchmanFileMonitor, self).start() 75 | 76 | def join(self): 77 | try: 78 | return super(WatchmanFileMonitor, self).join() 79 | finally: 80 | self._close_sock() 81 | 82 | def stop(self): 83 | self.enabled = False 84 | self._close_sock() 85 | 86 | def run(self): 87 | while self.enabled: 88 | try: 89 | result = self._recv() 90 | except socket.timeout: 91 | continue 92 | except OSError as ex: 93 | if ex.errno == errno.EBADF: 94 | # this means the socket is closed which should only happen 95 | # when stop is invoked, leaving enabled false 96 | if self.enabled: 97 | self.logger.error( 98 | 'Lost connection to watchman. No longer watching' 99 | ' for changes.' 100 | ) 101 | break 102 | raise 103 | 104 | self._handle_result(result) 105 | 106 | def _handle_result(self, result): 107 | if 'warning' in result: 108 | self.logger.error('watchman warning: ' + result['warning']) 109 | 110 | if 'error' in result: 111 | self.logger.error('watchman error: ' + result['error']) 112 | 113 | if 'subscription' in result: 114 | root = result['root'] 115 | 116 | if result.get('canceled'): 117 | self.logger.info( 118 | 'watchman has stopped following root: ' + root 119 | ) 120 | with self.lock: 121 | self.watches.remove(root) 122 | 123 | else: 124 | files = result['files'] 125 | with self.lock: 126 | for f in files: 127 | if isinstance(f, dict): 128 | f = f['name'] 129 | path = os.path.join(root, f) 130 | if path in self.paths: 131 | self.callback(path) 132 | 133 | if not self._is_unilateral(result): 134 | self.responses.put(result) 135 | 136 | def _is_unilateral(self, result): 137 | if 'unilateral' in result and result['unilateral']: 138 | return True 139 | # fallback to checking for known unilateral responses 140 | for k in ['log', 'subscription']: 141 | if k in result: 142 | return True 143 | return False 144 | 145 | def _close_sock(self): 146 | if self._sock: 147 | try: 148 | self._sock.close() 149 | except Exception: 150 | pass 151 | finally: 152 | self._sock = None 153 | 154 | def _resolve_sockpath(self): 155 | if self.sockpath: 156 | return self.sockpath 157 | return get_watchman_sockpath(self.binpath) 158 | 159 | def _watch(self, root): 160 | result = self._query(['watch-project', root]) 161 | if result['watch'] != root: 162 | root = result['watch'] 163 | self._query( 164 | [ 165 | 'subscribe', 166 | root, 167 | '{}.{}.{}'.format(os.getpid(), id(self), root), 168 | { 169 | # +1 second because we don't want any buffered changes 170 | # if the daemon is already watching the folder 171 | 'since': int(time.time() + 1), 172 | 'expression': ['type', 'f'], 173 | 'fields': ['name'], 174 | }, 175 | ] 176 | ) 177 | self.logger.debug('watchman is now tracking root: ' + root) 178 | with self.lock: 179 | self.watches.add(root) 180 | 181 | def _readline(self): 182 | # buffer may already have a line 183 | if len(self._recvbufs) == 1 and b'\n' in self._recvbufs[0]: 184 | line, b = self._recvbufs[0].split(b'\n', 1) 185 | self._recvbufs = [b] 186 | return line 187 | 188 | while True: 189 | # use select because it unblocks immediately when the socket is 190 | # closed unlike sock.settimeout which does not 191 | ready_r, _, _ = select.select([self._sock], [], [], self.timeout) 192 | if self._sock not in ready_r: 193 | continue 194 | b = self._sock.recv(4096) 195 | if not b: 196 | self.logger.error( 197 | 'Lost connection to watchman. No longer watching for' 198 | ' changes.' 199 | ) 200 | self.stop() 201 | raise socket.timeout 202 | if b'\n' in b: 203 | result = b''.join(self._recvbufs) 204 | line, b = b.split(b'\n', 1) 205 | self._recvbufs = [b] 206 | return result + line 207 | self._recvbufs.append(b) 208 | 209 | def _recv(self): 210 | line = self._readline().decode('utf8') 211 | try: 212 | return json.loads(line) 213 | except Exception: # pragma: no cover 214 | self.logger.info( 215 | 'Ignoring corrupted payload from watchman: ' + line 216 | ) 217 | return {} 218 | 219 | def _send(self, msg): 220 | cmd = json.dumps(msg).encode('ascii') 221 | self._sock.sendall(cmd + b'\n') 222 | 223 | def _query(self, msg, timeout=None): 224 | self._send(msg) 225 | return self.responses.get(timeout=timeout) 226 | -------------------------------------------------------------------------------- /src/hupper/winapi.py: -------------------------------------------------------------------------------- 1 | import ctypes 2 | from ctypes import WINFUNCTYPE, wintypes 3 | 4 | kernel32 = ctypes.WinDLL('kernel32', use_last_error=True) 5 | 6 | if ctypes.sizeof(ctypes.c_void_p) == 8: 7 | ULONG_PTR = ctypes.c_int64 8 | else: 9 | ULONG_PTR = ctypes.c_ulong 10 | BOOL = wintypes.BOOL 11 | DWORD = wintypes.DWORD 12 | HANDLE = wintypes.HANDLE 13 | LARGE_INTEGER = wintypes.LARGE_INTEGER 14 | SIZE_T = ULONG_PTR 15 | ULONGLONG = ctypes.c_uint64 16 | PHANDLER_ROUTINE = WINFUNCTYPE(BOOL, DWORD) 17 | 18 | JobObjectAssociateCompletionPortInformation = 7 19 | JobObjectBasicLimitInformation = 2 20 | JobObjectBasicUIRestrictions = 4 21 | JobObjectEndOfJobTimeInformation = 6 22 | JobObjectExtendedLimitInformation = 9 23 | JobObjectSecurityLimitInformation = 5 24 | JobObjectGroupInformation = 11 25 | 26 | JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE = 0x2000 27 | 28 | DELETE = 0x00010000 29 | READ_CONTROL = 0x00020000 30 | SYNCHRONIZE = 0x00100000 31 | WRITE_DAC = 0x00040000 32 | WRITE_OWNER = 0x00080000 33 | STANDARD_RIGHTS_REQUIRED = DELETE | READ_CONTROL | WRITE_DAC | WRITE_OWNER 34 | 35 | PROCESS_CREATE_PROCESS = 0x0080 36 | PROCESS_CREATE_THREAD = 0x0002 37 | PROCESS_DUP_HANDLE = 0x0040 38 | PROCESS_QUERY_INFORMATION = 0x0400 39 | PROCESS_QUERY_LIMITED_INFORMATION = 0x1000 40 | PROCESS_SET_INFORMATION = 0x0200 41 | PROCESS_SET_QUOTA = 0x0100 42 | PROCESS_SUSPEND_RESUME = 0x0800 43 | PROCESS_TERMINATE = 0x0001 44 | PROCESS_VM_OPERATION = 0x0008 45 | PROCESS_VM_READ = 0x0010 46 | PROCESS_VM_WRITE = 0x0020 47 | PROCESS_ALL_ACCESS = STANDARD_RIGHTS_REQUIRED | SYNCHRONIZE | 0xFFF 48 | 49 | DUPLICATE_SAME_ACCESS = 0x0002 50 | 51 | HANDLE_FLAG_INHERIT = 0x0001 52 | HANDLE_FLAG_PROTECT_FROM_CLOSE = 0x0002 53 | 54 | 55 | class IO_COUNTERS(ctypes.Structure): 56 | _fields_ = [ 57 | ('ReadOperationCount', ULONGLONG), 58 | ('WriteOperationCount', ULONGLONG), 59 | ('OtherOperationCount', ULONGLONG), 60 | ('ReadTransferCount', ULONGLONG), 61 | ('WriteTransferCount', ULONGLONG), 62 | ('OtherTransferCount', ULONGLONG), 63 | ] 64 | 65 | 66 | class JOBOBJECT_BASIC_LIMIT_INFORMATION(ctypes.Structure): 67 | _fields_ = [ 68 | ('PerProcessUserTimeLimit', LARGE_INTEGER), 69 | ('PerJobUserTimeLimit', LARGE_INTEGER), 70 | ('LimitFlags', DWORD), 71 | ('MinimumWorkingSetSize', SIZE_T), 72 | ('MaximumWorkingSetSize', SIZE_T), 73 | ('ActiveProcessLimit', DWORD), 74 | ('Affinity', ULONG_PTR), 75 | ('PriorityClass', DWORD), 76 | ('SchedulingClass', DWORD), 77 | ] 78 | 79 | 80 | class JOBOBJECT_EXTENDED_LIMIT_INFORMATION(ctypes.Structure): 81 | _fields_ = [ 82 | ('BasicLimitInformation', JOBOBJECT_BASIC_LIMIT_INFORMATION), 83 | ('IoInfo', IO_COUNTERS), 84 | ('ProcessMemoryLimit', SIZE_T), 85 | ('JobMemoryLimit', SIZE_T), 86 | ('PeakProcessMemoryUsed', SIZE_T), 87 | ('PeakJobMemoryUsed', SIZE_T), 88 | ] 89 | 90 | 91 | class Handle(HANDLE): 92 | closed = False 93 | 94 | def Close(self): 95 | if not self.closed: 96 | self.closed = True 97 | CloseHandle(self) 98 | 99 | def Detach(self): 100 | if not self.closed: 101 | self.closed = True 102 | return self.value 103 | raise ValueError("already closed") 104 | 105 | def __repr__(self): 106 | return "%s(%d)" % (self.__class__.__name__, self.value) 107 | 108 | __del__ = Close 109 | __str__ = __repr__ 110 | 111 | 112 | def CloseHandle(h): 113 | kernel32.CloseHandle(h) 114 | 115 | 116 | def CheckError(result, msg): 117 | if not result: 118 | raise ctypes.WinError(ctypes.get_last_error(), msg) 119 | 120 | 121 | def DuplicateHandle( 122 | hSourceProcess, 123 | hSourceHandle, 124 | hTargetProcess, 125 | desiredAccess, 126 | inheritHandle, 127 | options, 128 | ): 129 | targetHandle = wintypes.HANDLE() 130 | ret = kernel32.DuplicateHandle( 131 | hSourceProcess, 132 | hSourceHandle, 133 | hTargetProcess, 134 | ctypes.byref(targetHandle), 135 | desiredAccess, 136 | inheritHandle, 137 | options, 138 | ) 139 | CheckError(ret, 'failed to duplicate handle') 140 | return Handle(targetHandle.value) 141 | 142 | 143 | def GetCurrentProcess(): 144 | hp = kernel32.GetCurrentProcess() 145 | return Handle(hp) 146 | 147 | 148 | def OpenProcess(desiredAccess, inherit, pid): 149 | hp = kernel32.OpenProcess(desiredAccess, inherit, pid) 150 | CheckError(hp, 'failed to open process') 151 | return Handle(hp) 152 | 153 | 154 | def CreateJobObject(jobAttributes, name): 155 | hp = kernel32.CreateJobObjectA(jobAttributes, name) 156 | CheckError(hp, 'failed to create job object') 157 | return Handle(hp) 158 | 159 | 160 | def SetInformationJobObject(hJob, infoType, jobObjectInfo): 161 | ret = kernel32.SetInformationJobObject( 162 | hJob, 163 | infoType, 164 | ctypes.byref(jobObjectInfo), 165 | ctypes.sizeof(jobObjectInfo), 166 | ) 167 | CheckError(ret, 'failed to set information job object') 168 | 169 | 170 | def AssignProcessToJobObject(hJob, hProcess): 171 | ret = kernel32.AssignProcessToJobObject(hJob, hProcess) 172 | CheckError(ret, 'failed to assign process to job object') 173 | 174 | 175 | def SetHandleInformation(h, dwMask, dwFlags): 176 | ret = kernel32.SetHandleInformation(h, dwMask, dwFlags) 177 | CheckError(ret, 'failed to set handle information') 178 | 179 | 180 | CTRL_C_EVENT = 0 181 | CTRL_BREAK_EVENT = 1 182 | CTRL_CLOSE_EVENT = 2 183 | CTRL_LOGOFF_EVENT = 5 184 | CTRL_SHUTDOWN_EVENT = 6 185 | 186 | 187 | def SetConsoleCtrlHandler(handler, add): 188 | SetConsoleCtrlHandler = kernel32.SetConsoleCtrlHandler 189 | SetConsoleCtrlHandler.argtypes = (PHANDLER_ROUTINE, BOOL) 190 | SetConsoleCtrlHandler.restype = BOOL 191 | 192 | ret = SetConsoleCtrlHandler(handler, add) 193 | CheckError(ret, 'failed in to set console ctrl handler') 194 | 195 | 196 | def AddConsoleCtrlHandler(handler): 197 | @PHANDLER_ROUTINE 198 | def console_handler(ctrl_type): 199 | if ctrl_type in ( 200 | CTRL_C_EVENT, 201 | CTRL_BREAK_EVENT, 202 | CTRL_CLOSE_EVENT, 203 | CTRL_LOGOFF_EVENT, 204 | CTRL_SHUTDOWN_EVENT, 205 | ): 206 | handler() 207 | return True 208 | return False 209 | 210 | SetConsoleCtrlHandler(console_handler, True) 211 | return lambda: SetConsoleCtrlHandler(console_handler, False) 212 | -------------------------------------------------------------------------------- /src/hupper/worker.py: -------------------------------------------------------------------------------- 1 | from _thread import interrupt_main 2 | from importlib.util import source_from_cache 3 | import os 4 | import signal 5 | import site 6 | import sys 7 | import sysconfig 8 | import threading 9 | import time 10 | import traceback 11 | 12 | from . import ipc 13 | from .interfaces import IReloaderProxy 14 | from .utils import resolve_spec 15 | 16 | 17 | class WatchSysModules(threading.Thread): 18 | """Poll ``sys.modules`` for imported modules.""" 19 | 20 | poll_interval = 1 21 | ignore_system_paths = True 22 | 23 | def __init__(self, callback): 24 | super(WatchSysModules, self).__init__() 25 | self.paths = set() 26 | self.callback = callback 27 | self.lock = threading.Lock() 28 | self.stopped = False 29 | self.system_paths = get_system_paths() 30 | 31 | def run(self): 32 | while not self.stopped: 33 | self.update_paths() 34 | time.sleep(self.poll_interval) 35 | 36 | def stop(self): 37 | self.stopped = True 38 | 39 | def update_paths(self): 40 | """Check sys.modules for paths to add to our path set.""" 41 | new_paths = [] 42 | with self.lock: 43 | for path in expand_source_paths(iter_module_paths()): 44 | if path not in self.paths: 45 | self.paths.add(path) 46 | new_paths.append(path) 47 | if new_paths: 48 | self.watch_paths(new_paths) 49 | 50 | def search_traceback(self, tb): 51 | """Inspect a traceback for new paths to add to our path set.""" 52 | new_paths = [] 53 | with self.lock: 54 | for filename, *_ in traceback.extract_tb(tb): 55 | path = os.path.abspath(filename) 56 | if path not in self.paths: 57 | self.paths.add(path) 58 | new_paths.append(path) 59 | if new_paths: 60 | self.watch_paths(new_paths) 61 | 62 | def watch_paths(self, paths): 63 | if self.ignore_system_paths: 64 | paths = [path for path in paths if not self.in_system_paths(path)] 65 | if paths: 66 | self.callback(paths) 67 | 68 | def in_system_paths(self, path): 69 | # use realpath to only ignore files that live in a system path 70 | # versus a symlink which lives elsewhere 71 | path = os.path.realpath(path) 72 | for prefix in self.system_paths: 73 | if path.startswith(prefix): 74 | return True 75 | return False 76 | 77 | 78 | def get_py_path(path): 79 | try: 80 | return source_from_cache(path) 81 | except ValueError: 82 | # fallback for solitary *.pyc files outside of __pycache__ 83 | return path[:-1] 84 | 85 | 86 | def get_site_packages(): # pragma: no cover 87 | try: 88 | paths = site.getsitepackages() 89 | if site.ENABLE_USER_SITE: 90 | paths.append(site.getusersitepackages()) 91 | return paths 92 | 93 | # virtualenv does not ship with a getsitepackages impl so we fallback 94 | # to using distutils if we can 95 | # https://github.com/pypa/virtualenv/issues/355 96 | except Exception: 97 | try: 98 | from distutils.sysconfig import get_python_lib 99 | 100 | return [get_python_lib()] 101 | 102 | # just incase, don't fail here, it's not worth it 103 | except Exception: 104 | return [] 105 | 106 | 107 | def get_system_paths(): 108 | paths = get_site_packages() 109 | for name in {'stdlib', 'platstdlib', 'platlib', 'purelib'}: 110 | path = sysconfig.get_path(name) 111 | if path is not None: 112 | paths.append(path) 113 | return paths 114 | 115 | 116 | def expand_source_paths(paths): 117 | """Convert pyc files into their source equivalents.""" 118 | for src_path in paths: 119 | # only track the source path if we can find it to avoid double-reloads 120 | # when the source and the compiled path change because on some 121 | # platforms they are not changed at the same time 122 | if src_path.endswith(('.pyc', '.pyo')): 123 | py_path = get_py_path(src_path) 124 | if os.path.exists(py_path): 125 | src_path = py_path 126 | yield src_path 127 | 128 | 129 | def iter_module_paths(modules=None): 130 | """Yield paths of all imported modules.""" 131 | modules = modules or list(sys.modules.values()) 132 | for module in modules: 133 | try: 134 | filename = module.__file__ 135 | except (AttributeError, ImportError): # pragma: no cover 136 | continue 137 | if filename is not None: 138 | abs_filename = os.path.abspath(filename) 139 | if os.path.isfile(abs_filename): 140 | yield abs_filename 141 | 142 | 143 | class Worker: 144 | """A helper object for managing a worker process lifecycle.""" 145 | 146 | def __init__(self, spec, args=None, kwargs=None): 147 | super(Worker, self).__init__() 148 | self.worker_spec = spec 149 | self.worker_args = args 150 | self.worker_kwargs = kwargs 151 | self.pipe, self._child_pipe = ipc.Pipe() 152 | self.pid = None 153 | self.process = None 154 | self.exitcode = None 155 | self.stdin_termios = None 156 | 157 | def start(self, on_packet=None): 158 | self.stdin_termios = ipc.snapshot_termios(sys.stdin) 159 | 160 | kw = dict( 161 | spec=self.worker_spec, 162 | spec_args=self.worker_args, 163 | spec_kwargs=self.worker_kwargs, 164 | pipe=self._child_pipe, 165 | ) 166 | self.process = ipc.spawn( 167 | __name__ + '.worker_main', 168 | kwargs=kw, 169 | pass_fds=[self._child_pipe.r_fd, self._child_pipe.w_fd], 170 | ) 171 | self.pid = self.process.pid 172 | 173 | # activate the pipe after forking 174 | self.pipe.activate(on_packet) 175 | 176 | # kill the child side of the pipe after forking as the child is now 177 | # responsible for it 178 | self._child_pipe.close() 179 | 180 | @property 181 | def is_alive(self): 182 | if self.exitcode is not None: 183 | return False 184 | if self.process: 185 | return ipc.wait(self.process, timeout=0) is None 186 | return False 187 | 188 | def kill(self, soft=False): 189 | return ipc.kill(self.process, soft=soft) 190 | 191 | def wait(self, timeout=None): 192 | return ipc.wait(self.process, timeout=timeout) 193 | 194 | def join(self): 195 | self.exitcode = self.wait() 196 | 197 | if self.stdin_termios: 198 | ipc.restore_termios(sys.stdin, self.stdin_termios) 199 | 200 | if self.pipe: 201 | try: 202 | self.pipe.close() 203 | except Exception: # pragma: no cover 204 | pass 205 | finally: 206 | self.pipe = None 207 | 208 | 209 | # set when the current process is being monitored 210 | _reloader_proxy = None 211 | 212 | 213 | def get_reloader(): 214 | """ 215 | Get a reference to the current :class:`hupper.interfaces.IReloaderProxy`. 216 | 217 | Raises a ``RuntimeError`` if the current process is not actively being 218 | monitored by a parent process. 219 | 220 | """ 221 | if _reloader_proxy is None: 222 | raise RuntimeError('process is not controlled by hupper') 223 | return _reloader_proxy 224 | 225 | 226 | def is_active(): 227 | """ 228 | Return ``True`` if the current process being monitored by a parent process. 229 | 230 | """ 231 | return _reloader_proxy is not None 232 | 233 | 234 | class ReloaderProxy(IReloaderProxy): 235 | def __init__(self, pipe): 236 | self.pipe = pipe 237 | 238 | def watch_files(self, files): 239 | files = [os.path.abspath(f) for f in files] 240 | self.pipe.send(('watch_files', files)) 241 | 242 | def trigger_reload(self): 243 | self.pipe.send(('reload',)) 244 | 245 | def graceful_shutdown(self): 246 | self.pipe.send(('graceful_shutdown',)) 247 | 248 | 249 | def watch_control_pipe(pipe): 250 | def handle_packet(packet): 251 | if packet is None: 252 | interrupt_main() 253 | 254 | pipe.activate(handle_packet) 255 | 256 | 257 | def worker_main(spec, pipe, spec_args=None, spec_kwargs=None): 258 | if spec_args is None: 259 | spec_args = [] 260 | if spec_kwargs is None: 261 | spec_kwargs = {} 262 | 263 | # activate the pipe after forking 264 | watch_control_pipe(pipe) 265 | 266 | # SIGHUP is not supported on windows 267 | if hasattr(signal, 'SIGHUP'): 268 | signal.signal(signal.SIGHUP, signal.SIG_IGN) 269 | 270 | # disable pyc files for project code because it can cause timestamp 271 | # issues in which files are reloaded twice 272 | sys.dont_write_bytecode = True 273 | 274 | global _reloader_proxy 275 | _reloader_proxy = ReloaderProxy(pipe) 276 | 277 | poller = WatchSysModules(_reloader_proxy.watch_files) 278 | poller.daemon = True 279 | poller.start() 280 | 281 | # import the worker path before polling sys.modules 282 | func = resolve_spec(spec) 283 | 284 | # start the worker 285 | try: 286 | func(*spec_args, **spec_kwargs) 287 | except BaseException: # catch any error 288 | try: 289 | # add files from the traceback before crashing 290 | poller.search_traceback(sys.exc_info()[2]) 291 | except Exception: # pragma: no cover 292 | pass 293 | raise 294 | finally: 295 | try: 296 | # attempt to send imported paths to the reloader process prior to 297 | # closing 298 | poller.update_paths() 299 | poller.stop() 300 | poller.join() 301 | except Exception: # pragma: no cover 302 | pass 303 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Pylons/hupper/b2c564c915bbb94999602820a587f39be02eee29/tests/__init__.py -------------------------------------------------------------------------------- /tests/conftest.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | import sys 3 | 4 | from . import util 5 | 6 | 7 | def err(msg): # pragma: no cover 8 | print(msg, file=sys.stderr) 9 | 10 | 11 | @pytest.hookimpl(tryfirst=True, hookwrapper=True) 12 | def pytest_runtest_makereport(item, call): 13 | # execute all other hooks to obtain the report object 14 | outcome = yield 15 | rep = outcome.get_result() 16 | 17 | # set an report attribute for each phase of a call, which can 18 | # be "setup", "call", "teardown" 19 | setattr(item, "rep_" + rep.when, rep) 20 | 21 | 22 | @pytest.fixture 23 | def testapp(request): 24 | app = util.TestApp() 25 | try: 26 | yield app 27 | finally: 28 | app.stop() 29 | if ( 30 | request.node.rep_call.failed and app.exitcode is not None 31 | ): # pragma: no cover 32 | err( 33 | '-- test app failed --\nname=%s\nargs=%s\ncode=%s' 34 | % (app.name, app.args, app.exitcode) 35 | ) 36 | err('-- stdout --\n%s' % app.stdout) 37 | err('-- stderr --\n%s' % app.stderr) 38 | 39 | 40 | class DummyLogger: 41 | def __init__(self): 42 | self.messages = [] 43 | 44 | def reset(self): 45 | self.messages = [] 46 | 47 | def get_output(self, *levels): 48 | if not levels: 49 | levels = {'info', 'error', 'debug'} 50 | return '\n'.join(msg for lvl, msg in self.messages if lvl in levels) 51 | 52 | def info(self, msg): 53 | self.messages.append(('info', msg)) 54 | 55 | def error(self, msg): 56 | self.messages.append(('error', msg)) 57 | 58 | def debug(self, msg): 59 | self.messages.append(('debug', msg)) 60 | 61 | 62 | @pytest.fixture 63 | def logger(): 64 | return DummyLogger() 65 | -------------------------------------------------------------------------------- /tests/myapp/__init__.py: -------------------------------------------------------------------------------- 1 | import pytest_cov.embed 2 | import signal 3 | import sys 4 | 5 | 6 | def cleanup(*args, **kwargs): # pragma: no cover 7 | # see https://github.com/pytest-dev/pytest-cov/issues/139 8 | pytest_cov.embed.cleanup() 9 | sys.exit(1) 10 | 11 | 12 | signal.signal(signal.SIGTERM, cleanup) 13 | -------------------------------------------------------------------------------- /tests/myapp/__main__.py: -------------------------------------------------------------------------------- 1 | import sys 2 | 3 | from .cli import main 4 | 5 | sys.exit(main(sys.argv[1:]) or 0) 6 | -------------------------------------------------------------------------------- /tests/myapp/cli.py: -------------------------------------------------------------------------------- 1 | import argparse 2 | import os 3 | import sys 4 | import time 5 | 6 | import hupper 7 | 8 | here = os.path.dirname(__file__) 9 | 10 | 11 | def parse_options(args): 12 | parser = argparse.ArgumentParser() 13 | parser.add_argument('--reload', action='store_true') 14 | parser.add_argument('--callback-file') 15 | parser.add_argument( 16 | '--watch-file', action='append', dest='watch_files', default=[] 17 | ) 18 | parser.add_argument('--watchman', action='store_true') 19 | parser.add_argument('--watchdog', action='store_true') 20 | parser.add_argument('--poll', action='store_true') 21 | parser.add_argument('--poll-interval', type=int) 22 | parser.add_argument('--reload-interval', type=int) 23 | parser.add_argument('--shutdown-interval', type=int) 24 | return parser.parse_args(args) 25 | 26 | 27 | def main(args=None): 28 | if args is None: 29 | args = sys.argv[1:] 30 | opts = parse_options(args) 31 | if opts.reload: 32 | kw = {} 33 | if opts.poll: 34 | from hupper.polling import PollingFileMonitor 35 | 36 | pkw = {} 37 | if opts.poll_interval: 38 | pkw['poll_interval'] = opts.poll_interval 39 | kw['monitor_factory'] = lambda cb: PollingFileMonitor(cb, **pkw) 40 | 41 | if opts.watchdog: 42 | from hupper.watchdog import WatchdogFileMonitor 43 | 44 | kw['monitor_factory'] = WatchdogFileMonitor 45 | 46 | if opts.watchman: 47 | from hupper.watchman import WatchmanFileMonitor 48 | 49 | kw['monitor_factory'] = WatchmanFileMonitor 50 | 51 | if opts.reload_interval is not None: 52 | kw['reload_interval'] = opts.reload_interval 53 | 54 | if opts.shutdown_interval is not None: 55 | kw['shutdown_interval'] = opts.shutdown_interval 56 | 57 | hupper.start_reloader(__name__ + '.main', **kw) 58 | 59 | if hupper.is_active(): 60 | hupper.get_reloader().watch_files([os.path.join(here, 'foo.ini')]) 61 | hupper.get_reloader().watch_files(opts.watch_files) 62 | 63 | if opts.callback_file: 64 | with open(opts.callback_file, 'ab') as fp: 65 | fp.write('{:d}\n'.format(int(time.time())).encode('utf8')) 66 | try: 67 | while True: 68 | time.sleep(1) 69 | except KeyboardInterrupt: 70 | pass 71 | -------------------------------------------------------------------------------- /tests/myapp/foo.ini: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Pylons/hupper/b2c564c915bbb94999602820a587f39be02eee29/tests/myapp/foo.ini -------------------------------------------------------------------------------- /tests/test_cli.py: -------------------------------------------------------------------------------- 1 | import argparse 2 | import pytest 3 | 4 | from hupper.cli import interval_parser 5 | 6 | 7 | @pytest.mark.parametrize('value', ['0', "-1"]) 8 | def test_interval_parser_errors(value): 9 | with pytest.raises(argparse.ArgumentTypeError): 10 | interval_parser(value) 11 | 12 | 13 | def test_interval_parser(): 14 | assert interval_parser("5") == 5 15 | -------------------------------------------------------------------------------- /tests/test_ipc.py: -------------------------------------------------------------------------------- 1 | import queue 2 | 3 | from hupper.ipc import Pipe, spawn 4 | 5 | 6 | def echo(pipe): 7 | q = queue.Queue() 8 | pipe.activate(q.put) 9 | msg = q.get() 10 | while msg is not None: 11 | pipe.send(msg) 12 | msg = q.get() 13 | pipe.close() 14 | 15 | 16 | def test_ipc_close(): 17 | c1, c2 = Pipe() 18 | c1_q = queue.Queue() 19 | c1.activate(c1_q.put) 20 | 21 | with spawn( 22 | __name__ + '.echo', 23 | kwargs={"pipe": c2}, 24 | pass_fds=[c2.r_fd, c2.w_fd], 25 | ) as proc: 26 | try: 27 | c2.close() 28 | 29 | c1.send("hello world") 30 | assert c1_q.get() == "hello world" 31 | 32 | c1.close() 33 | finally: 34 | proc.terminate() 35 | -------------------------------------------------------------------------------- /tests/test_it.py: -------------------------------------------------------------------------------- 1 | import os.path 2 | import time 3 | 4 | from . import util 5 | 6 | here = os.path.abspath(os.path.dirname(__file__)) 7 | 8 | 9 | def test_myapp_reloads_when_touching_ini(testapp): 10 | testapp.start('myapp', ['--reload']) 11 | testapp.wait_for_response() 12 | time.sleep(2) 13 | util.touch(os.path.join(here, 'myapp/foo.ini')) 14 | testapp.wait_for_response() 15 | testapp.stop() 16 | 17 | assert len(testapp.response) == 2 18 | assert testapp.stderr != '' 19 | 20 | 21 | def test_myapp_reloads_when_touching_pyfile(testapp): 22 | testapp.start('myapp', ['--reload']) 23 | testapp.wait_for_response() 24 | time.sleep(2) 25 | util.touch(os.path.join(here, 'myapp/cli.py')) 26 | testapp.wait_for_response() 27 | testapp.stop() 28 | 29 | assert len(testapp.response) == 2 30 | assert testapp.stderr != '' 31 | -------------------------------------------------------------------------------- /tests/test_reloader.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | here = os.path.abspath(os.path.dirname(__file__)) 4 | 5 | 6 | class DummyCallback: 7 | called = False 8 | 9 | def __call__(self, paths): 10 | self.called = paths 11 | 12 | 13 | def make_proxy(monitor_factory, callback, logger): 14 | from hupper.reloader import FileMonitorProxy 15 | 16 | proxy = FileMonitorProxy(callback, logger) 17 | proxy.monitor = monitor_factory(proxy.file_changed) 18 | return proxy 19 | 20 | 21 | def test_proxy_proxies(logger): 22 | class DummyMonitor: 23 | started = stopped = joined = False 24 | 25 | def __call__(self, cb, **kw): 26 | self.cb = cb 27 | return self 28 | 29 | def start(self): 30 | self.started = True 31 | 32 | def stop(self): 33 | self.stopped = True 34 | 35 | def join(self): 36 | self.joined = True 37 | 38 | cb = DummyCallback() 39 | monitor = DummyMonitor() 40 | proxy = make_proxy(monitor, cb, logger) 41 | assert monitor.cb 42 | assert not monitor.started and not monitor.stopped and not monitor.joined 43 | proxy.start() 44 | assert monitor.started and not monitor.stopped and not monitor.joined 45 | proxy.stop() 46 | assert monitor.stopped and monitor.joined 47 | 48 | 49 | def test_proxy_expands_paths(tmpdir, logger): 50 | class DummyMonitor: 51 | def __call__(self, cb, **kw): 52 | self.cb = cb 53 | self.paths = [] 54 | return self 55 | 56 | def add_path(self, path): 57 | self.paths.append(path) 58 | 59 | cb = DummyCallback() 60 | monitor = DummyMonitor() 61 | proxy = make_proxy(monitor, cb, logger) 62 | proxy.add_path('foo') 63 | assert monitor.paths == ['foo'] 64 | 65 | tmpdir.join('foo.txt').ensure() 66 | tmpdir.join('bar.txt').ensure() 67 | rootdir = tmpdir.strpath 68 | monitor.paths = [] 69 | proxy.add_path(os.path.join(rootdir, '*.txt')) 70 | assert sorted(monitor.paths) == [ 71 | os.path.join(rootdir, 'bar.txt'), 72 | os.path.join(rootdir, 'foo.txt'), 73 | ] 74 | 75 | 76 | def test_proxy_tracks_changes(logger): 77 | class DummyMonitor: 78 | def __call__(self, cb, **kw): 79 | self.cb = cb 80 | return self 81 | 82 | cb = DummyCallback() 83 | monitor = DummyMonitor() 84 | proxy = make_proxy(monitor, cb, logger) 85 | monitor.cb('foo.txt') 86 | assert cb.called == {'foo.txt'} 87 | out = logger.get_output('info') 88 | assert out == 'foo.txt changed; reloading ...' 89 | logger.reset() 90 | monitor.cb('foo.txt') 91 | out = logger.get_output('info') 92 | assert out == '' 93 | logger.reset() 94 | cb.called = False 95 | proxy.clear_changes() 96 | monitor.cb('foo.txt') 97 | out = logger.get_output('info') 98 | assert out == 'foo.txt changed; reloading ...' 99 | logger.reset() 100 | 101 | 102 | def test_ignore_files(): 103 | class DummyMonitor: 104 | paths = set() 105 | 106 | def add_path(self, path): 107 | self.paths.add(path) 108 | 109 | from hupper.reloader import FileMonitorProxy 110 | 111 | cb = DummyCallback() 112 | proxy = FileMonitorProxy(cb, None, {'/a/*'}) 113 | monitor = proxy.monitor = DummyMonitor() 114 | 115 | path = 'foo.txt' 116 | assert path not in monitor.paths 117 | proxy.add_path(path) 118 | assert path in monitor.paths 119 | 120 | path = '/a/foo.txt' 121 | assert path not in monitor.paths 122 | proxy.add_path(path) 123 | assert path not in monitor.paths 124 | -------------------------------------------------------------------------------- /tests/util.py: -------------------------------------------------------------------------------- 1 | import os 2 | import subprocess 3 | import sys 4 | import tempfile 5 | import threading 6 | import time 7 | 8 | here = os.path.abspath(os.path.dirname(__file__)) 9 | 10 | 11 | class TestApp(threading.Thread): 12 | name = None 13 | args = None 14 | stdin = None 15 | daemon = True 16 | 17 | def __init__(self): 18 | super(TestApp, self).__init__() 19 | self.exitcode = None 20 | self.process = None 21 | self.tmpfile = None 22 | self.tmpsize = 0 23 | self.response = None 24 | self.stdout, self.stderr = b'', b'' 25 | 26 | def start(self, name, args): 27 | self.name = name 28 | self.args = args or [] 29 | 30 | fd, self.tmpfile = tempfile.mkstemp() 31 | os.close(fd) 32 | touch(self.tmpfile) 33 | self.tmpsize = os.path.getsize(self.tmpfile) 34 | self.response = readfile(self.tmpfile) 35 | super(TestApp, self).start() 36 | 37 | def run(self): 38 | cmd = [sys.executable, '-m', 'tests.' + self.name] 39 | if self.tmpfile: 40 | cmd += ['--callback-file', self.tmpfile] 41 | cmd += self.args 42 | 43 | env = os.environ.copy() 44 | env['PYTHONUNBUFFERED'] = '1' 45 | 46 | self.process = subprocess.Popen( 47 | cmd, 48 | stdin=subprocess.PIPE, 49 | stdout=subprocess.PIPE, 50 | stderr=subprocess.PIPE, 51 | env=env, 52 | universal_newlines=True, 53 | ) 54 | try: 55 | self.stdout, self.stderr = self.process.communicate(self.stdin) 56 | finally: 57 | self.exitcode = self.process.wait() 58 | 59 | def is_alive(self): 60 | return self.process is not None and self.exitcode is None 61 | 62 | def stop(self): 63 | if self.is_alive(): 64 | self.process.kill() 65 | self.join() 66 | 67 | if self.tmpfile: 68 | os.unlink(self.tmpfile) 69 | self.tmpfile = None 70 | 71 | def wait_for_response(self, timeout=5, interval=0.1): 72 | self.tmpsize = wait_for_change( 73 | self.tmpfile, 74 | last_size=self.tmpsize, 75 | timeout=timeout, 76 | interval=interval, 77 | ) 78 | self.response = readfile(self.tmpfile) 79 | 80 | 81 | def touch(fname, times=None): 82 | with open(fname, 'a'): 83 | os.utime(fname, times) 84 | 85 | 86 | def readfile(path): 87 | with open(path, 'rb') as fp: 88 | return fp.readlines() 89 | 90 | 91 | def wait_for_change(path, last_size=0, timeout=5, interval=0.1): 92 | start = time.time() 93 | size = os.path.getsize(path) 94 | while size == last_size: 95 | duration = time.time() - start 96 | sleepfor = interval 97 | if timeout is not None: # pragma: no cover 98 | if duration >= timeout: 99 | raise RuntimeError( 100 | 'timeout waiting for change to file=%s' % (path,) 101 | ) 102 | sleepfor = min(timeout - duration, sleepfor) 103 | time.sleep(sleepfor) 104 | size = os.path.getsize(path) 105 | return size 106 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [tox] 2 | envlist = 3 | lint, 4 | py37,py38,py39,py310,py311,py312,pypy3, 5 | docs,coverage 6 | 7 | isolated_build = true 8 | 9 | requires = 10 | pip >= 19 11 | 12 | [testenv] 13 | commands = 14 | py.test --cov --cov-report= {posargs:} 15 | 16 | setenv = 17 | COVERAGE_FILE=.coverage.{envname} 18 | 19 | extras = 20 | testing 21 | 22 | [testenv:coverage] 23 | skip_install = true 24 | commands = 25 | coverage combine 26 | coverage report 27 | deps = 28 | coverage 29 | setenv = 30 | COVERAGE_FILE=.coverage 31 | 32 | [testenv:docs] 33 | allowlist_externals = 34 | make 35 | commands = 36 | make -C docs html BUILDDIR={envdir} SPHINXOPTS="-W -E" 37 | extras = 38 | docs 39 | 40 | [testenv:lint] 41 | skip_install = True 42 | commands = 43 | isort --check-only --df src/hupper tests setup.py 44 | black --check --diff src/hupper tests setup.py 45 | flake8 src/hupper tests setup.py 46 | check-manifest 47 | # build sdist/wheel 48 | python -m build . 49 | twine check dist/* 50 | deps = 51 | black 52 | build 53 | check-manifest 54 | flake8 55 | flake8-bugbear 56 | isort 57 | readme_renderer 58 | twine 59 | 60 | [testenv:format] 61 | skip_install = true 62 | commands = 63 | isort src/hupper tests setup.py 64 | black src/hupper tests setup.py 65 | deps = 66 | black 67 | isort 68 | 69 | [testenv:build] 70 | skip_install = true 71 | commands = 72 | # clean up build/ and dist/ folders 73 | python -c 'import shutil; shutil.rmtree("build", ignore_errors=True)' 74 | # Make sure we aren't forgetting anything 75 | check-manifest 76 | # build sdist/wheel 77 | python -m build . 78 | # Verify all is well 79 | twine check dist/* 80 | 81 | deps = 82 | build 83 | check-manifest 84 | readme_renderer 85 | twine 86 | --------------------------------------------------------------------------------