diff --git a/.github/workflows/prek.yml b/.github/workflows/prek.yml
new file mode 100644
index 0000000..917b259
--- /dev/null
+++ b/.github/workflows/prek.yml
@@ -0,0 +1,27 @@
+name: "Prek Checks"
+on:
+ push:
+ branches: [master]
+ pull_request:
+permissions: {}
+jobs:
+ prek:
+ name: "Run prek"
+ runs-on: ubuntu-latest
+ permissions:
+ contents: read
+ steps:
+ - name: "Checkout code"
+ uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
+ with:
+ persist-credentials: false
+ - name: "Install uv"
+ uses: astral-sh/setup-uv@c18668ad3cf93ea998bef934396af7bb5c839dc7 # v10.2.0
+ with:
+ enable-cache: true
+ - name: "Set up Python"
+ run: uv python install
+ - name: "Install dependencies"
+ run: uv sync --locked --all-extras --dev
+ - name: "Run prek"
+ uses: j178/prek-action@4e14d07f9231acabce116ccfca13b13dd9755ece # v3.0.0
diff --git a/.github/workflows/pypi.yml b/.github/workflows/pypi.yml
index a186c75..343643b 100644
--- a/.github/workflows/pypi.yml
+++ b/.github/workflows/pypi.yml
@@ -1,69 +1,48 @@
-name: Release theme to PyPI
-
-permissions:
- contents: write
- id-token: write
-
+name: "PyPI Release"
on:
- push:
- tags:
- - v2.*
-
+ workflow_dispatch:
+permissions: {}
jobs:
build:
- name: build dist files
+ name: "Build distributions"
+ # Only publish from a release tag, never from a branch.
+ if: startsWith(github.ref, 'refs/tags/v')
runs-on: ubuntu-latest
-
+ permissions:
+ contents: read
steps:
- - uses: actions/checkout@v4
-
- - uses: actions/setup-python@v5
- with:
- python-version: 3.11
-
- - name: install build
- run: python -m pip install --upgrade build
-
- - name: build dist
- run: python -m build
-
- - uses: actions/upload-artifact@v4
- with:
- name: artifacts
- path: dist/*
- if-no-files-found: error
-
- publish:
+ - name: "Checkout code"
+ uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
+ with:
+ persist-credentials: false
+ - name: "Set up python"
+ uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
+ with:
+ python-version-file: .python-version
+ - name: "Install Hatch"
+ run: pipx install hatch
+ - name: "Build package"
+ run: hatch build
+ - name: "Upload distributions"
+ uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
+ with:
+ name: dist
+ path: dist/
+ if-no-files-found: error
+ pypi-publish:
+ name: "Upload release to PyPI"
+ needs: build
+ runs-on: ubuntu-latest
environment:
name: pypi-release
url: https://pypi.org/p/livereload
-
- name: release to pypi
- needs: build
- runs-on: ubuntu-latest
-
- steps:
- - uses: actions/download-artifact@v4
- with:
- name: artifacts
- path: dist
-
- - name: Push build artifacts to PyPI
- uses: pypa/gh-action-pypi-publish@release/v1
-
- release:
- name: write release note
- runs-on: ubuntu-latest
- needs: publish
-
+ permissions:
+ id-token: write
steps:
- - uses: actions/checkout@v4
- with:
- fetch-depth: 0
- - uses: actions/setup-node@v4
- with:
- node-version: 18
- - run: npx changelogithub --no-group
- continue-on-error: true
- env:
- GITHUB_TOKEN: ${{secrets.GITHUB_TOKEN}}
+ - name: "Download distributions"
+ uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
+ with:
+ name: dist
+ path: dist/
+ - name: "Publish package distributions to PyPI"
+ uses: pypa/gh-action-pypi-publish@dc37677b2e1c63e2034f94d8a5b11f265b73ba33 # v1.14.2
diff --git a/.github/workflows/pytest.yml b/.github/workflows/pytest.yml
new file mode 100644
index 0000000..2c4b9cb
--- /dev/null
+++ b/.github/workflows/pytest.yml
@@ -0,0 +1,31 @@
+name: "Run Tests (pytest)"
+on:
+ push:
+ branches: [master]
+ pull_request:
+ workflow_dispatch:
+permissions: {}
+jobs:
+ tests:
+ name: "Run pytest"
+ runs-on: ubuntu-latest
+ permissions:
+ contents: read
+ strategy:
+ fail-fast: false
+ matrix:
+ python-version: ['3.10', '3.11', '3.12', '3.13', '3.14']
+ steps:
+ - name: "Checkout code"
+ uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
+ with:
+ persist-credentials: false
+ - name: "Install uv and set up Python version"
+ uses: astral-sh/setup-uv@c18668ad3cf93ea998bef934396af7bb5c839dc7 # v10.2.0
+ with:
+ enable-cache: true
+ python-version: ${{ matrix.python-version }}
+ - name: "Install dependencies"
+ run: uv sync --locked --all-extras --dev
+ - name: "Run pytest"
+ run: uv run pytest
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
new file mode 100644
index 0000000..ff63178
--- /dev/null
+++ b/.github/workflows/release.yml
@@ -0,0 +1,28 @@
+name: "GitHub Release"
+on:
+ push:
+ tags: ["v*"]
+permissions: {}
+jobs:
+ release:
+ name: "Write release notes"
+ runs-on: ubuntu-latest
+ permissions:
+ contents: write
+ steps:
+ - name: "Checkout code"
+ uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
+ with:
+ persist-credentials: false
+ fetch-depth: 0
+ # NOTE: changing to gh specific actions can help drop node setup
+ - name: "Setup node"
+ uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0
+ with:
+ node-version: 24
+ package-manager-cache: false
+ - name: "Generate changelog"
+ run: npx changelogithub --no-group
+ continue-on-error: true
+ env:
+ GITHUB_TOKEN: ${{secrets.GITHUB_TOKEN}}
diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml
new file mode 100644
index 0000000..396af97
--- /dev/null
+++ b/.pre-commit-config.yaml
@@ -0,0 +1,109 @@
+# See https://pre-commit.com for more information
+# See https://pre-commit.com/hooks.html for more hooks
+default_install_hook_types: ["pre-commit", "commit-msg", "pre-push"]
+
+repos:
+ - repo: "https://github.com/compilerla/conventional-pre-commit"
+ rev: "v4.4.0"
+ hooks:
+ - id: "conventional-pre-commit"
+ stages: ["commit-msg"]
+
+ - repo: "https://github.com/pre-commit/pre-commit-hooks"
+ rev: "v6.0.0"
+ hooks:
+ - id: "trailing-whitespace"
+ - id: "end-of-file-fixer"
+ - id: "check-toml"
+ - id: "check-yaml"
+ - id: "mixed-line-ending"
+ - id: "check-merge-conflict"
+ - id: "detect-private-key"
+ - id: "check-shebang-scripts-are-executable"
+ - id: "check-executables-have-shebangs"
+
+ - repo: "meta"
+ hooks:
+ - id: "check-useless-excludes"
+
+ - repo: "https://github.com/gitleaks/gitleaks"
+ rev: "v8.30.1"
+ hooks:
+ - id: "gitleaks"
+
+ - repo: "https://github.com/zizmorcore/zizmor-pre-commit"
+ rev: "v1.30.1"
+ hooks:
+ - id: "zizmor"
+
+ # - repo: "https://github.com/astral-sh/ruff-pre-commit"
+ # rev: "v0.16.10"
+ # hooks:
+ # - id: "ruff-format"
+ # - id: "ruff-check"
+
+ # - repo: "https://github.com/DetachHead/basedpyright-prek-mirror"
+ # rev: "1.40.1"
+ # hooks:
+ # - id: "basedpyright"
+
+ - repo: "https://github.com/tox-dev/pyproject-fmt"
+ rev: "v2.30.0"
+ hooks:
+ - id: "pyproject-fmt"
+ files: "^pyproject\\.toml$"
+
+ - repo: "https://github.com/tombi-toml/tombi-pre-commit"
+ rev: "v1.7.1"
+ hooks:
+ - id: "tombi-format"
+ exclude: "(^pyproject\\.toml$|^uv\\.lock$)"
+ - id: "tombi-lint"
+
+ - repo: "https://github.com/google/yamlfmt"
+ rev: "v0.21.0"
+ hooks:
+ - id: "yamlfmt"
+
+ - repo: "https://github.com/rhysd/actionlint"
+ rev: "v1.7.12"
+ hooks:
+ - id: "actionlint"
+ files: "^\\.github/workflows/.*\\.ya?ml$"
+
+ - repo: "https://github.com/adrienverge/yamllint.git"
+ rev: "v1.38.0"
+ hooks:
+ - id: "yamllint"
+ exclude: "^\\.github/workflows/.*\\.ya?ml$"
+
+ - repo: "https://github.com/rvben/rumdl-pre-commit"
+ rev: "v0.2.78"
+ hooks:
+ - id: "rumdl-fmt"
+ - id: "rumdl"
+
+ - repo: "https://github.com/JoC0de/pre-commit-prettier"
+ rev: "v3.9.9"
+ hooks:
+ - id: "prettier"
+ types_or: ["html", "css", "javascript"]
+
+ - repo: "https://github.com/codespell-project/codespell"
+ rev: "v2.4.3"
+ hooks:
+ - id: "codespell"
+
+ - repo: "https://github.com/astral-sh/uv-pre-commit"
+ rev: "0.12.23"
+ hooks:
+ - id: "uv-lock"
+
+ - repo: "local"
+ hooks:
+ - id: "pytest"
+ name: "pytest"
+ entry: "uv run pytest"
+ pass_filenames: false
+ language: "system"
+ stages: ["pre-push"]
diff --git a/.prettierignore b/.prettierignore
new file mode 100644
index 0000000..4f70101
--- /dev/null
+++ b/.prettierignore
@@ -0,0 +1 @@
+src/livereload/vendors/livereload.js
diff --git a/.python-version b/.python-version
new file mode 100644
index 0000000..6324d40
--- /dev/null
+++ b/.python-version
@@ -0,0 +1 @@
+3.14
diff --git a/.yamllint.yaml b/.yamllint.yaml
new file mode 100644
index 0000000..f53eec5
--- /dev/null
+++ b/.yamllint.yaml
@@ -0,0 +1,3 @@
+extends: default
+rules:
+ document-start: disable
diff --git a/MANIFEST.in b/MANIFEST.in
deleted file mode 100644
index 2d661dd..0000000
--- a/MANIFEST.in
+++ /dev/null
@@ -1,3 +0,0 @@
-include livereload/vendors/livereload.js
-include LICENSE
-include README.rst
diff --git a/Makefile b/Makefile
index fa27900..59df01f 100644
--- a/Makefile
+++ b/Makefile
@@ -1,4 +1,5 @@
-.PHONY: clean-pyc clean-build docs test coverage
+# .PHONY: clean-pyc clean-build docs test coverage lint typecheck check build install
+.PHONY: clean-pyc clean-build docs test coverage check build install
clean: clean-build clean-pyc
@@ -15,14 +16,25 @@ clean-pyc:
find . -name '*~' -exec rm -f {} +
install:
- python3 setup.py install
+ uv sync
+
+build:
+ uv build
docs:
$(MAKE) -C docs html
test:
- @nosetests -s
+ uv run pytest
+
+# lint:
+# uv run ruff check .
+
+# typecheck:
+# uv run basedpyright
+
+# check: lint typecheck test
+check: test
coverage:
- @rm -f .coverage
- @nosetests --with-coverage --cover-package=livereload --cover-html
+ uv run --with pytest-cov pytest --cov=livereload --cov-report=html
diff --git a/docs/conf.py b/docs/conf.py
index 7b7bcc8..a2430e8 100644
--- a/docs/conf.py
+++ b/docs/conf.py
@@ -1,5 +1,4 @@
-"""A sphinx documentation configuration file.
-"""
+"""A sphinx documentation configuration file."""
# -- Project information ---------------------------------------------------------------
# https://www.sphinx-doc.org/en/master/usage/configuration.html#project-information
diff --git a/example/index.html b/example/index.html
index 56c7031..ee08971 100644
--- a/example/index.html
+++ b/example/index.html
@@ -1,12 +1,12 @@
-
+
-
-
- Python LiveReload Example
-
-
-
- Example
- Container
-
+
+
+ Python LiveReload Example
+
+
+
+ Example
+ Container
+
diff --git a/example/server.py b/example/server.py
index f0960b6..6991229 100644
--- a/example/server.py
+++ b/example/server.py
@@ -1,7 +1,5 @@
-#!/usr/bin/env python
-
from livereload import Server, shell
server = Server()
-server.watch('style.less', shell('lessc style.less', output='style.css'))
+server.watch("style.less", shell("lessc style.less", output="style.css"))
server.serve(open_url_delay=1)
diff --git a/livereload/__init__.py b/livereload/__init__.py
deleted file mode 100644
index aeb788c..0000000
--- a/livereload/__init__.py
+++ /dev/null
@@ -1,17 +0,0 @@
-"""
- livereload
- ~~~~~~~~~~
-
- A python version of livereload.
-
- :copyright: (c) 2013 by Hsiaoming Yang
- :license: BSD, see LICENSE for more details.
-"""
-
-__version__ = '2.7.1'
-__author__ = 'Hsiaoming Yang '
-__homepage__ = 'https://github.com/lepture/python-livereload'
-
-from .server import Server, shell
-
-__all__ = ('Server', 'shell')
diff --git a/livereload/cli.py b/livereload/cli.py
deleted file mode 100644
index a50f917..0000000
--- a/livereload/cli.py
+++ /dev/null
@@ -1,61 +0,0 @@
-import argparse
-
-import tornado.log
-
-from livereload.server import Server
-
-
-parser = argparse.ArgumentParser(description='Start a `livereload` server')
-parser.add_argument(
- '--host',
- help='Hostname to run `livereload` server on',
- type=str,
- default='127.0.0.1'
-)
-parser.add_argument(
- '-p', '--port',
- help='Port to run `livereload` server on',
- type=int,
- default=35729
-)
-parser.add_argument(
- 'directory',
- help='Directory to serve files from',
- type=str,
- default='.',
- nargs='?'
-)
-parser.add_argument(
- '-t', '--target',
- help='File or directory to watch for changes',
- type=str,
-)
-parser.add_argument(
- '-w', '--wait',
- help='Time delay in seconds before reloading',
- type=float,
- default=0.0
-)
-parser.add_argument(
- '-o', '--open-url-delay',
- help='If set, triggers browser opening seconds after starting',
- type=float
-)
-parser.add_argument(
- '-d', '--debug',
- help='Enable Tornado pretty logging',
- action='store_true'
-)
-
-
-def main(argv=None):
- args = parser.parse_args()
-
- if args.debug:
- tornado.log.enable_pretty_logging()
-
- # Create a new application
- server = Server()
- server.watcher.watch(args.target or args.directory, delay=args.wait)
- server.serve(host=args.host, port=args.port, root=args.directory,
- open_url_delay=args.open_url_delay)
diff --git a/livereload/management/commands/livereload.py b/livereload/management/commands/livereload.py
deleted file mode 100644
index 1b79fba..0000000
--- a/livereload/management/commands/livereload.py
+++ /dev/null
@@ -1,43 +0,0 @@
-import os
-import re
-from django.core.management.base import BaseCommand, CommandError
-from django.core.management.commands.runserver import naiveip_re
-from django.core.servers.basehttp import get_internal_wsgi_application
-from livereload import Server
-
-
-class Command(BaseCommand):
- help = 'Runs the development server with livereload enabled.'
-
- def add_arguments(self, parser):
- parser.add_argument('addrport',
- nargs='?',
- default='127.0.0.1:8000',
- help='host and optional port the django server should listen on (default: 127.0.0.1:8000)')
- parser.add_argument('-l', '--liveport',
- type=int,
- default=35729,
- help='port the livereload server should listen on (default: 35729)')
-
- def handle(self, *args, **options):
- m = re.match(naiveip_re, options['addrport'])
- if m is None:
- raise CommandError('"%s" is not a valid port number '
- 'or address:port pair.' % options['addrport'])
- addr, _ipv4, _ipv6, _fqdn, port = m.groups()
- if not port.isdigit():
- raise CommandError("%r is not a valid port number." % port)
-
- if addr:
- if _ipv6:
- raise CommandError('IPv6 addresses are currently not supported.')
-
-
- application = get_internal_wsgi_application()
- server = Server(application)
-
- for file in os.listdir('.'):
- if file[0] != '.' and file[:2] != '__' and os.path.isdir(file):
- server.watch(file)
-
- server.serve(host=addr, port=port, liveport=options['liveport'])
diff --git a/pyproject.toml b/pyproject.toml
new file mode 100644
index 0000000..5365c7f
--- /dev/null
+++ b/pyproject.toml
@@ -0,0 +1,95 @@
+[build-system]
+build-backend = "hatchling.build"
+requires = [ "hatchling>=1.26" ]
+
+[project]
+name = "livereload"
+description = "Python LiveReload is an awesome tool for web developers"
+readme = "README.md"
+keywords = [
+ "autoreload",
+ "browser-refresh",
+ "development-server",
+ "django",
+ "file-watcher",
+ "live-reload",
+ "livereload",
+ "static-server",
+ "tornado",
+ "wsgi",
+]
+license = "BSD-3-Clause"
+license-files = [ "LICEN[CS]E*" ]
+authors = [ { name = "Hsiaoming Yang", email = "me@lepture.com" } ]
+requires-python = ">=3.10"
+classifiers = [
+ "Development Status :: 4 - Beta",
+ "Environment :: Console",
+ "Environment :: Web Environment :: Mozilla",
+ "Intended Audience :: Developers",
+ "Natural Language :: English",
+ "Operating System :: MacOS :: MacOS X",
+ "Operating System :: POSIX :: Linux",
+ "Programming Language :: Python",
+ "Programming Language :: Python :: 3 :: Only",
+ "Programming Language :: Python :: 3.10",
+ "Programming Language :: Python :: 3.11",
+ "Programming Language :: Python :: 3.12",
+ "Programming Language :: Python :: 3.13",
+ "Programming Language :: Python :: 3.14",
+ "Programming Language :: Python :: 3.15",
+ "Programming Language :: Python :: Implementation :: CPython",
+ "Programming Language :: Python :: Implementation :: PyPy",
+ "Topic :: Software Development :: Build Tools",
+ "Topic :: Software Development :: Compilers",
+ "Topic :: Software Development :: Debuggers",
+]
+dynamic = [ "version" ]
+dependencies = [
+ # 6.1 handles the default Windows proactor event loop on its own.
+ "tornado>=6.1",
+ # typing.override is only available from Python 3.12.
+ "typing-extensions>=4.4; python_version<'3.12'",
+]
+optional-dependencies.notify = [
+ # pyinotify imports asyncore, which was removed in Python 3.12.
+ "pyinotify>=0.9.6; python_version<'3.12'",
+]
+urls.Changelog = "https://github.com/lepture/python-livereload/blob/master/CHANGES.rst"
+urls.Documentation = "https://livereload.readthedocs.io"
+urls.Issues = "https://github.com/lepture/python-livereload/issues"
+urls.Repository = "https://github.com/lepture/python-livereload"
+scripts.livereload = "livereload.cli:main"
+
+[dependency-groups]
+dev = [
+ "django-stubs>=6.0.9",
+ "prek>=0.5.5",
+ "pytest>=8.4.2",
+ "typing-extensions>=4.4",
+]
+
+[tool.hatch]
+version.path = "src/livereload/__init__.py"
+build.targets.wheel.packages = [ "src/livereload" ]
+build.targets.sdist.include = [ "/src", "/tests", "/docs", "/CHANGES.rst" ]
+
+[tool.ruff]
+line-length = 80
+lint.extend-select = [ "ANN" ]
+lint.unfixable = [ "F401" ]
+
+# Tests might fail in 3.10 and 3.11
+[tool.pytest]
+ini_options.filterwarnings = [
+ # pyinotify imports asyncore, deprecated on Python 3.10 and 3.11.
+ "ignore:The asyncore module is deprecated:DeprecationWarning",
+]
+
+[tool.basedpyright]
+# Check against the oldest supported Python
+pythonVersion = "3.10"
+reportUnusedCallResult = false
+executionEnvironments = [
+ { root = "tests", reportPrivateUsage = false },
+]
diff --git a/rumdl.toml b/rumdl.toml
new file mode 100644
index 0000000..1483955
--- /dev/null
+++ b/rumdl.toml
@@ -0,0 +1,24 @@
+[global]
+disable = [
+ "MD025", # Allow multiple h1 headings
+ "MD026", # Permit punctuation in heading
+ "MD033", # Permit inline HTML
+ "MD040", # Bare fences are intentional (shell snippets)
+ "MD041", # Allow first line to be anything (not just h1)
+]
+extend-enable = ["MD060"] # Table alignment
+respect-gitignore = true
+
+[MD013] # Line length
+line-length = 160
+code-blocks = false
+tables = false
+headings = false
+
+[MD060] # Table alignment
+enabled = true
+style = "aligned"
+
+[per-file-flavor]
+# Sphinx docs use MyST directives such as ```{toctree}.
+"docs/**/*.md" = "myst"
diff --git a/server.py b/server.py
index 0528a8a..2ba0bee 100644
--- a/server.py
+++ b/server.py
@@ -1,5 +1,5 @@
from livereload import Server, shell
server = Server()
-server.watch('docs/*.rst', shell('make html'))
-server.serve(root='docs/_build/html')
+server.watch("docs/*.rst", shell("make html"))
+server.serve(root="docs/_build/html")
diff --git a/setup.cfg b/setup.cfg
deleted file mode 100644
index 8183238..0000000
--- a/setup.cfg
+++ /dev/null
@@ -1,2 +0,0 @@
-[metadata]
-license_files = LICENSE
diff --git a/setup.py b/setup.py
deleted file mode 100644
index 1cbe18c..0000000
--- a/setup.py
+++ /dev/null
@@ -1,63 +0,0 @@
-#!/usr/bin/env python
-
-import re
-from setuptools import setup
-
-
-def fread(filepath):
- with open(filepath) as f:
- return f.read()
-
-
-def version():
- content = fread('livereload/__init__.py')
- pattern = r"__version__ = '([0-9\.dev]*)'"
- m = re.findall(pattern, content)
- return m[0]
-
-
-setup(
- name='livereload',
- version=version(),
- author='Hsiaoming Yang',
- author_email='me@lepture.com',
- url='https://github.com/lepture/python-livereload',
- packages=['livereload', 'livereload.management.commands'],
- description='Python LiveReload is an awesome tool for web developers',
- long_description_content_type='text/x-rst',
- long_description=fread('README.md'),
- entry_points={
- 'console_scripts': [
- 'livereload = livereload.cli:main',
- ]
- },
- install_requires=[
- 'tornado',
- ],
- license='BSD',
- include_package_data=True,
- python_requires='>=3.7',
- classifiers=[
- 'Development Status :: 4 - Beta',
- 'Environment :: Console',
- 'Environment :: Web Environment :: Mozilla',
- 'Intended Audience :: Developers',
- 'License :: OSI Approved :: BSD License',
- 'Natural Language :: English',
- 'Operating System :: MacOS :: MacOS X',
- 'Operating System :: POSIX :: Linux',
- 'Programming Language :: Python',
- 'Programming Language :: Python :: 3',
- 'Programming Language :: Python :: 3.7',
- 'Programming Language :: Python :: 3.8',
- 'Programming Language :: Python :: 3.9',
- 'Programming Language :: Python :: 3.10',
- 'Programming Language :: Python :: 3.11',
- 'Programming Language :: Python :: 3 :: Only',
- 'Programming Language :: Python :: Implementation :: CPython',
- 'Programming Language :: Python :: Implementation :: PyPy',
- 'Topic :: Software Development :: Build Tools',
- 'Topic :: Software Development :: Compilers',
- 'Topic :: Software Development :: Debuggers',
- ]
-)
diff --git a/src/livereload/__init__.py b/src/livereload/__init__.py
new file mode 100644
index 0000000..ae97727
--- /dev/null
+++ b/src/livereload/__init__.py
@@ -0,0 +1,15 @@
+"""
+livereload
+~~~~~~~~~~
+
+A python version of livereload.
+
+:copyright: (c) 2013 by Hsiaoming Yang
+:license: BSD, see LICENSE for more details.
+"""
+
+__version__ = "2.7.1"
+
+from .server import Server, shell
+
+__all__ = ("Server", "shell")
diff --git a/livereload/__main__.py b/src/livereload/__main__.py
similarity index 100%
rename from livereload/__main__.py
rename to src/livereload/__main__.py
diff --git a/src/livereload/cli.py b/src/livereload/cli.py
new file mode 100644
index 0000000..4ef7831
--- /dev/null
+++ b/src/livereload/cli.py
@@ -0,0 +1,94 @@
+import argparse
+from typing import cast
+
+import tornado.log
+
+from livereload import __version__ as VERSION
+from livereload.server import Server
+
+parser = argparse.ArgumentParser(
+ prog="livereload",
+ description="Start a `livereload` server",
+ allow_abbrev=False,
+)
+
+parser.add_argument(
+ "-V",
+ "--version",
+ action="version",
+ version=f"%(prog)s {VERSION}",
+)
+parser.add_argument(
+ "-d",
+ "--debug",
+ help="enable Tornado pretty logging",
+ action="store_true",
+)
+parser.add_argument(
+ "--host",
+ help="hostname to run `livereload` server on",
+ type=str,
+ default="127.0.0.1",
+)
+parser.add_argument(
+ "-p",
+ "--port",
+ help="port to run `livereload` server on",
+ type=int,
+ default=35729,
+)
+parser.add_argument(
+ "directory",
+ help="directory to serve files from",
+ type=str,
+ default=".",
+ nargs="?",
+)
+parser.add_argument(
+ "-t",
+ "--target",
+ help="file or directory to watch for changes",
+ type=str,
+)
+parser.add_argument(
+ "-w",
+ "--wait",
+ help="time delay in seconds before reloading",
+ type=float,
+ default=0.0,
+ metavar="DELAY",
+)
+parser.add_argument(
+ "-o",
+ "--open-url-delay",
+ help="if set, triggers browser opening seconds after starting",
+ type=float,
+ metavar="DELAY",
+)
+
+
+def main(argv: list[str] | None = None) -> None:
+ args = parser.parse_args(argv)
+ host = cast(str, args.host)
+ port = cast(int, args.port)
+ directory = cast(str, args.directory)
+ target = cast(str | None, args.target)
+ wait = cast(float, args.wait)
+ open_url_delay = cast(float | None, args.open_url_delay)
+
+ if cast(bool, args.debug):
+ tornado.log.enable_pretty_logging()
+
+ # Create a new application
+ server = Server()
+ server.watcher.watch(target or directory, delay=wait) # pyright: ignore[reportArgumentType]
+ server.serve(
+ host=host,
+ port=port,
+ root=directory,
+ open_url_delay=open_url_delay,
+ )
+
+
+if __name__ == "__main__":
+ main()
diff --git a/livereload/handlers.py b/src/livereload/handlers.py
similarity index 75%
rename from livereload/handlers.py
rename to src/livereload/handlers.py
index b8ec488..f3555ad 100644
--- a/livereload/handlers.py
+++ b/src/livereload/handlers.py
@@ -1,26 +1,26 @@
"""
- livereload.handlers
- ~~~~~~~~~~~~~~~~~~~
+livereload.handlers
+~~~~~~~~~~~~~~~~~~~
- HTTP and WebSocket handlers for livereload.
+HTTP and WebSocket handlers for livereload.
- :copyright: (c) 2013 by Hsiaoming Yang
- :license: BSD, see LICENSE for more details.
+:copyright: (c) 2013 by Hsiaoming Yang
+:license: BSD, see LICENSE for more details.
"""
+
import datetime
import hashlib
+import logging
import os
import stat
import time
-import logging
-from tornado import web
-from tornado import ioloop
-from tornado import escape
+
+from tornado import escape, ioloop, web
from tornado.log import gen_log
-from tornado.websocket import WebSocketHandler
from tornado.util import ObjectDict
+from tornado.websocket import WebSocketHandler
-logger = logging.getLogger('livereload')
+logger = logging.getLogger("livereload")
class LiveReloadHandler(WebSocketHandler):
@@ -47,7 +47,7 @@ def send_message(self, message):
try:
self.write_message(message)
except:
- logger.error('Error sending message', exc_info=True)
+ logger.error("Error sending message", exc_info=True)
@classmethod
def start_tasks(cls):
@@ -55,31 +55,31 @@ def start_tasks(cls):
return
if not cls.watcher._tasks:
- logger.info('Watch current working directory')
+ logger.info("Watch current working directory")
cls.watcher.watch(os.getcwd())
cls._last_reload_time = time.time()
- logger.info('Start watching changes')
+ logger.info("Start watching changes")
if not cls.watcher.start(cls.poll_tasks):
- logger.info('Start detecting changes')
+ logger.info("Start detecting changes")
ioloop.PeriodicCallback(cls.poll_tasks, 800).start()
@classmethod
def poll_tasks(cls):
filepath, delay = cls.watcher.examine()
- if not filepath or delay == 'forever' or not cls.waiters:
+ if not filepath or delay == "forever" or not cls.waiters:
return
reload_time = LiveReloadHandler.DEFAULT_RELOAD_TIME
if delay:
reload_time = max(3 - delay, 1)
- if filepath == '__livereload__':
+ if filepath == "__livereload__":
reload_time = 0
if time.time() - cls._last_reload_time < reload_time:
# if you changed lot of files in one time
# it will refresh too many times
- logger.info('Ignore: %s', filepath)
+ logger.info("Ignore: %s", filepath)
return
if delay:
loop = ioloop.IOLoop.current()
@@ -90,19 +90,19 @@ def poll_tasks(cls):
@classmethod
def reload_waiters(cls, path=None):
logger.info(
- 'Reload %s waiters: %s',
+ "Reload %s waiters: %s",
len(cls.waiters),
cls.watcher.filepath,
)
if path is None:
- path = cls.watcher.filepath or '*'
+ path = cls.watcher.filepath or "*"
msg = {
- 'command': 'reload',
- 'path': path,
- 'liveCSS': cls.live_css,
- 'liveImg': True,
+ "command": "reload",
+ "path": path,
+ "liveCSS": cls.live_css,
+ "liveImg": True,
}
cls._last_reload_time = time.time()
@@ -110,7 +110,7 @@ def reload_waiters(cls, path=None):
try:
waiter.write_message(msg)
except:
- logger.error('Error sending message', exc_info=True)
+ logger.error("Error sending message", exc_info=True)
cls.waiters.remove(waiter)
def on_message(self, message):
@@ -121,18 +121,18 @@ def on_message(self, message):
3. client send 'info'
"""
message = ObjectDict(escape.json_decode(message))
- if message.command == 'hello':
+ if message.command == "hello":
handshake = {
- 'command': 'hello',
- 'protocols': [
- 'http://livereload.com/protocols/official-7',
+ "command": "hello",
+ "protocols": [
+ "http://livereload.com/protocols/official-7",
],
- 'serverName': 'livereload-tornado',
+ "serverName": "livereload-tornado",
}
self.send_message(handshake)
- if message.command == 'info' and 'url' in message:
- logger.info('Browser Connected: %s' % message.url)
+ if message.command == "info" and "url" in message:
+ logger.info("Browser Connected: %s" % message.url)
LiveReloadHandler.waiters.add(self)
@@ -148,7 +148,8 @@ def get_content_modified_time(cls, abspath):
"""
stat_result = os.stat(abspath)
modified = datetime.datetime.utcfromtimestamp(
- stat_result[stat.ST_MTIME])
+ stat_result[stat.ST_MTIME]
+ )
return modified
@classmethod
@@ -163,7 +164,9 @@ def get_content_version(cls, abspath):
data = cls.get_content(abspath)
hasher = hashlib.md5()
- mtime_data = format(cls.get_content_modified_time(abspath), "%Y-%m-%d %H:%M:%S")
+ mtime_data = format(
+ cls.get_content_modified_time(abspath), "%Y-%m-%d %H:%M:%S"
+ )
hasher.update(mtime_data.encode())
@@ -211,20 +214,19 @@ def _load_version(abs_path):
class LiveReloadJSHandler(web.RequestHandler):
-
def get(self):
- self.set_header('Content-Type', 'application/javascript')
+ self.set_header("Content-Type", "application/javascript")
root = os.path.abspath(os.path.dirname(__file__))
- js_file = os.path.join(root, 'vendors/livereload.js')
- with open(js_file, 'rb') as f:
+ js_file = os.path.join(root, "vendors/livereload.js")
+ with open(js_file, "rb") as f:
self.write(f.read())
class ForceReloadHandler(web.RequestHandler):
def get(self):
- path = self.get_argument('path', default=None) or '*'
+ path = self.get_argument("path", default=None) or "*"
LiveReloadHandler.reload_waiters(path)
- self.write('ok')
+ self.write("ok")
class StaticFileHandler(MtimeStaticFileHandler):
diff --git a/livereload/management/__init__.py b/src/livereload/management/__init__.py
similarity index 100%
rename from livereload/management/__init__.py
rename to src/livereload/management/__init__.py
diff --git a/livereload/management/commands/__init__.py b/src/livereload/management/commands/__init__.py
similarity index 100%
rename from livereload/management/commands/__init__.py
rename to src/livereload/management/commands/__init__.py
diff --git a/src/livereload/management/commands/livereload.py b/src/livereload/management/commands/livereload.py
new file mode 100644
index 0000000..7c91a5d
--- /dev/null
+++ b/src/livereload/management/commands/livereload.py
@@ -0,0 +1,51 @@
+import os
+import re
+from django.core.management.base import BaseCommand, CommandError
+from django.core.management.commands.runserver import naiveip_re
+from django.core.servers.basehttp import get_internal_wsgi_application
+from livereload import Server
+
+
+class Command(BaseCommand):
+ help = "Runs the development server with livereload enabled."
+
+ def add_arguments(self, parser):
+ parser.add_argument(
+ "addrport",
+ nargs="?",
+ default="127.0.0.1:8000",
+ help="host and optional port the django server should listen on (default: 127.0.0.1:8000)",
+ )
+ parser.add_argument(
+ "-l",
+ "--liveport",
+ type=int,
+ default=35729,
+ help="port the livereload server should listen on (default: 35729)",
+ )
+
+ def handle(self, *args, **options):
+ m = re.match(naiveip_re, options["addrport"])
+ if m is None:
+ raise CommandError(
+ '"%s" is not a valid port number '
+ "or address:port pair." % options["addrport"]
+ )
+ addr, _ipv4, _ipv6, _fqdn, port = m.groups()
+ if not port.isdigit():
+ raise CommandError("%r is not a valid port number." % port)
+
+ if addr:
+ if _ipv6:
+ raise CommandError(
+ "IPv6 addresses are currently not supported."
+ )
+
+ application = get_internal_wsgi_application()
+ server = Server(application)
+
+ for file in os.listdir("."):
+ if file[0] != "." and file[:2] != "__" and os.path.isdir(file):
+ server.watch(file)
+
+ server.serve(host=addr, port=port, liveport=options["liveport"])
diff --git a/livereload/server.py b/src/livereload/server.py
similarity index 77%
rename from livereload/server.py
rename to src/livereload/server.py
index 2ff2058..0f4251f 100644
--- a/livereload/server.py
+++ b/src/livereload/server.py
@@ -1,60 +1,66 @@
"""
- livereload.server
- ~~~~~~~~~~~~~~~~~
+livereload.server
+~~~~~~~~~~~~~~~~~
- WSGI app server for livereload.
+WSGI app server for livereload.
- :copyright: (c) 2013 - 2015 by Hsiaoming Yang
- :license: BSD, see LICENSE for more details.
+:copyright: (c) 2013 - 2015 by Hsiaoming Yang
+:license: BSD, see LICENSE for more details.
"""
+import errno
+import logging
import os
-import time
+import re
import shlex
-import logging
+import sys
import threading
+import time
import webbrowser
-import re
-from subprocess import Popen, PIPE
+from subprocess import PIPE, Popen
-from tornado.wsgi import WSGIContainer
-from tornado.ioloop import IOLoop
+from tornado import escape, httputil, web
from tornado.autoreload import add_reload_hook
-from tornado import web
-from tornado import escape
-from tornado import httputil
+from tornado.ioloop import IOLoop
from tornado.log import LogFormatter
-from .handlers import LiveReloadHandler, LiveReloadJSHandler
-from .handlers import ForceReloadHandler, StaticFileHandler
-from .watcher import get_watcher_class
-
-import sys
+from tornado.wsgi import WSGIContainer
-import errno
+from .handlers import (
+ ForceReloadHandler,
+ LiveReloadHandler,
+ LiveReloadJSHandler,
+ StaticFileHandler,
+)
+from .watcher import get_watcher_class
-if sys.version_info >= (3, 8) and sys.platform == 'win32':
+if sys.version_info >= (3, 8) and sys.platform == "win32":
import asyncio
+
asyncio.set_event_loop_policy(asyncio.WindowsSelectorEventLoopPolicy())
-logger = logging.getLogger('livereload')
+logger = logging.getLogger("livereload")
-HEAD_END_RE = re.compile(br'', re.IGNORECASE)
+HEAD_END_RE = re.compile(rb"", re.IGNORECASE)
def inject_script_at_head(content, script):
"""Inject script before closing tag, case-insensitive."""
- return HEAD_END_RE.sub(lambda match: script + match.group(0), content, count=1)
+ return HEAD_END_RE.sub(
+ lambda match: script + match.group(0), content, count=1
+ )
def set_header(fn, name, value):
"""Helper Function to Add HTTP headers to the server"""
+
def set_default_headers(self, *args, **kwargs):
fn(self, *args, **kwargs)
self.set_header(name, value)
+
return set_default_headers
-def shell(cmd, output=None, mode='w', cwd=None, shell=False):
+def shell(cmd, output=None, mode="w", cwd=None, shell=False):
"""Execute a shell command.
You can add a shell command::
@@ -83,8 +89,9 @@ def shell(cmd, output=None, mode='w', cwd=None, shell=False):
def run_shell():
try:
- p = Popen(cmd, stdin=PIPE, stdout=PIPE, stderr=PIPE, cwd=cwd,
- shell=shell)
+ p = Popen(
+ cmd, stdin=PIPE, stdout=PIPE, stderr=PIPE, cwd=cwd, shell=shell
+ )
except OSError as e:
logger.error(e)
if e.errno == errno.ENOENT: # file (command) not found
@@ -111,14 +118,14 @@ def transform_first_chunk(self, status_code, headers, chunk, finishing):
is_html = "html" in headers.get("Content-Type", "")
if is_html and HEAD_END_RE.search(chunk):
chunk = inject_script_at_head(chunk, self.script)
- if 'Content-Length' in headers:
- length = int(headers['Content-Length']) + len(self.script)
- headers['Content-Length'] = str(length)
+ if "Content-Length" in headers:
+ length = int(headers["Content-Length"]) + len(self.script)
+ headers["Content-Length"] = str(length)
return status_code, headers, chunk
class LiveScriptContainer(WSGIContainer):
- def __init__(self, wsgi_app, script=''):
+ def __init__(self, wsgi_app, script=""):
self.wsgi_app = wsgi_app
self.script = script
@@ -132,7 +139,8 @@ def start_response(status, response_headers, exc_info=None):
return response.append
app_response = self.wsgi_app(
- WSGIContainer(self.wsgi_app).environ(request), start_response)
+ WSGIContainer(self.wsgi_app).environ(request), start_response
+ )
try:
response.extend(app_response)
body = b"".join(response)
@@ -142,7 +150,7 @@ def start_response(status, response_headers, exc_info=None):
if not data:
raise Exception("WSGI app did not call start_response")
- status_code, reason = data["status"].split(' ', 1)
+ status_code, reason = data["status"].split(" ", 1)
status_code = int(status_code)
headers = data["headers"]
header_set = {k.lower() for (k, v) in headers}
@@ -153,22 +161,19 @@ def start_response(status, response_headers, exc_info=None):
if status_code != 304:
if "content-type" not in header_set:
- headers.append((
- "Content-Type",
- "application/octet-stream; charset=UTF-8"
- ))
+ headers.append(
+ ("Content-Type", "application/octet-stream; charset=UTF-8")
+ )
if "content-length" not in header_set:
headers.append(("Content-Length", str(len(body))))
if "server" not in header_set:
headers.append(("Server", "LiveServer"))
- start_line = httputil.ResponseStartLine(
- "HTTP/1.1", status_code, reason
- )
+ start_line = httputil.ResponseStartLine("HTTP/1.1", status_code, reason)
header_obj = httputil.HTTPHeaders()
for key, value in headers:
- if key.lower() == 'content-length':
+ if key.lower() == "content-length":
value = str(len(body))
header_obj.add(key, value)
request.connection.write_headers(start_line, header_obj, chunk=body)
@@ -190,6 +195,7 @@ class Server:
pyinotify and use INotifyWatcher() to avoid wasted
CPU usage.
"""
+
def __init__(self, app=None, watcher=None):
self.root = None
@@ -201,10 +207,10 @@ def __init__(self, app=None, watcher=None):
self.SFH = StaticFileHandler
def setHeader(self, name, value):
- """Add or override HTTP headers at the at the beginning of the
+ """Add or override HTTP headers at the at the beginning of the
request.
- Once you have initialized a server, you can add one or more
+ Once you have initialized a server, you can add one or more
headers before starting the server::
server.setHeader('Access-Control-Allow-Origin', '*')
@@ -215,7 +221,8 @@ def setHeader(self, name, value):
:param value: The value of the header field to be defined.
"""
StaticFileHandler.set_default_headers = set_header(
- StaticFileHandler.set_default_headers, name, value)
+ StaticFileHandler.set_default_headers, name, value
+ )
self.SFH = StaticFileHandler
def watch(self, filepath, func=None, delay=None, ignore=None):
@@ -248,17 +255,16 @@ def alert():
self.watcher.watch(filepath, func, delay, ignore=ignore)
- def application(self, port, host, liveport=None, debug=None,
- live_css=True):
+ def application(self, port, host, liveport=None, debug=None, live_css=True):
LiveReloadHandler.watcher = self.watcher
LiveReloadHandler.live_css = live_css
if debug is None and self.app:
debug = True
live_handlers = [
- (r'/livereload', LiveReloadHandler),
- (r'/forcereload', ForceReloadHandler),
- (r'/livereload.js', LiveReloadJSHandler)
+ (r"/livereload", LiveReloadHandler),
+ (r"/forcereload", ForceReloadHandler),
+ (r"/livereload.js", LiveReloadJSHandler),
]
# The livereload.js snippet.
@@ -267,16 +273,19 @@ def application(self, port, host, liveport=None, debug=None,
live_script = (
''
+ "document.head.appendChild(s);"
+ "})();"
)
if liveport:
live_script = escape.utf8(live_script % liveport)
else:
- live_script = escape.utf8(live_script % "(window.location.port || (window.location.protocol == 'https:' ? 443: 80))")
+ live_script = escape.utf8(
+ live_script
+ % "(window.location.port || (window.location.protocol == 'https:' ? 443: 80))"
+ )
web_handlers = self.get_web_handlers(live_script)
@@ -286,16 +295,14 @@ class ConfiguredTransform(LiveScriptInjector):
if not liveport:
handlers = live_handlers + web_handlers
app = web.Application(
- handlers=handlers,
- debug=debug,
- transforms=[ConfiguredTransform]
+ handlers=handlers, debug=debug, transforms=[ConfiguredTransform]
)
app.listen(port, address=host)
else:
app = web.Application(
handlers=web_handlers,
debug=debug,
- transforms=[ConfiguredTransform]
+ transforms=[ConfiguredTransform],
)
app.listen(port, address=host)
live = web.Application(handlers=live_handlers, debug=False)
@@ -304,17 +311,31 @@ class ConfiguredTransform(LiveScriptInjector):
def get_web_handlers(self, script):
if self.app:
fallback = LiveScriptContainer(self.app, script)
- return [(r'.*', web.FallbackHandler, {'fallback': fallback})]
+ return [(r".*", web.FallbackHandler, {"fallback": fallback})]
return [
- (r'/(.*)', self.SFH, {
- 'path': self.root or '.',
- 'default_filename': self.default_filename,
- }),
+ (
+ r"/(.*)",
+ self.SFH,
+ {
+ "path": self.root or ".",
+ "default_filename": self.default_filename,
+ },
+ ),
]
- def serve(self, port=5500, liveport=None, host=None, root=None, debug=None,
- open_url=False, restart_delay=2, open_url_delay=None,
- live_css=True, default_filename='index.html'):
+ def serve(
+ self,
+ port=5500,
+ liveport=None,
+ host=None,
+ root=None,
+ debug=None,
+ open_url=False,
+ restart_delay=2,
+ open_url_delay=None,
+ live_css=True,
+ default_filename="index.html",
+ ):
"""Start serve the server with the given port.
:param port: serve on this port, default is 5500
@@ -329,30 +350,32 @@ def serve(self, port=5500, liveport=None, host=None, root=None, debug=None,
Defaults to True
:param default_filename: launch this file from the selected root on startup
"""
- host = host or '127.0.0.1'
+ host = host or "127.0.0.1"
if root is not None:
self.root = root
self._setup_logging()
- logger.info(f'Serving on http://{host}:{port}')
+ logger.info(f"Serving on http://{host}:{port}")
self.default_filename = default_filename
self.application(
- port, host, liveport=liveport, debug=debug, live_css=live_css)
+ port, host, liveport=liveport, debug=debug, live_css=live_css
+ )
# Async open web browser after 5 sec timeout
if open_url:
- logger.error('Use `open_url_delay` instead of `open_url`')
+ logger.error("Use `open_url_delay` instead of `open_url`")
if open_url_delay is not None:
def opener():
time.sleep(open_url_delay)
- webbrowser.open(f'http://{host}:{port}')
+ webbrowser.open(f"http://{host}:{port}")
+
threading.Thread(target=opener).start()
try:
- self.watcher._changes.append(('__livereload__', restart_delay))
+ self.watcher._changes.append(("__livereload__", restart_delay))
LiveReloadHandler.start_tasks()
# When autoreload is triggered, initiate a shutdown of the IOLoop
add_reload_hook(lambda: IOLoop.instance().stop())
@@ -361,7 +384,7 @@ def opener():
# Once the IOLoop is stopped, the IOLoop can be closed to free resources
IOLoop.current().close(all_fds=True)
except KeyboardInterrupt:
- logger.info('Shutting down...')
+ logger.info("Shutting down...")
def _setup_logging(self):
logger.setLevel(logging.INFO)
@@ -371,4 +394,4 @@ def _setup_logging(self):
logger.addHandler(channel)
# need a tornado logging handler to prevent IOLoop._setup_logging
- logging.getLogger('tornado').addHandler(channel)
+ logging.getLogger("tornado").addHandler(channel)
diff --git a/livereload/vendors/livereload.js b/src/livereload/vendors/livereload.js
similarity index 100%
rename from livereload/vendors/livereload.js
rename to src/livereload/vendors/livereload.js
diff --git a/livereload/watcher.py b/src/livereload/watcher.py
similarity index 85%
rename from livereload/watcher.py
rename to src/livereload/watcher.py
index 09c3a1b..72805ea 100644
--- a/livereload/watcher.py
+++ b/src/livereload/watcher.py
@@ -1,11 +1,11 @@
"""
- livereload.watcher
- ~~~~~~~~~~~~~~~~~~
+livereload.watcher
+~~~~~~~~~~~~~~~~~~
- A file watch management for LiveReload Server.
+A file watch management for LiveReload Server.
- :copyright: (c) 2013 - 2015 by Hsiaoming Yang
- :license: BSD, see LICENSE for more details.
+:copyright: (c) 2013 - 2015 by Hsiaoming Yang
+:license: BSD, see LICENSE for more details.
"""
import glob
@@ -19,11 +19,12 @@
except ImportError:
pyinotify = None
-logger = logging.getLogger('livereload')
+logger = logging.getLogger("livereload")
class Watcher:
"""A file watcher registry."""
+
def __init__(self):
self._tasks = {}
@@ -40,7 +41,7 @@ def __init__(self):
self._start = time.time()
# list of ignored dirs
- self.ignored_dirs = ['.git', '.hg', '.svn', '.cvs']
+ self.ignored_dirs = [".git", ".hg", ".svn", ".cvs"]
def ignore_dirs(self, *args):
self.ignored_dirs.extend(args)
@@ -52,7 +53,7 @@ def remove_dirs_from_ignore(self, *args):
def ignore(self, filename):
"""Ignore a given filename or not."""
_, ext = os.path.splitext(filename)
- return ext in ['.pyc', '.pyo', '.o', '.swp']
+ return ext in [".pyc", ".pyo", ".o", ".swp"]
def watch(self, path, func=None, delay=0, ignore=None):
"""Add a task to watcher.
@@ -66,10 +67,10 @@ def watch(self, path, func=None, delay=0, ignore=None):
filepath.
"""
self._tasks[path] = {
- 'func': func,
- 'delay': delay,
- 'ignore': ignore,
- 'mtimes': {},
+ "func": func,
+ "delay": delay,
+ "ignore": ignore,
+ "mtimes": {},
}
def start(self, callback):
@@ -90,20 +91,21 @@ def examine(self):
delays = set()
for path in self._tasks:
item = self._tasks[path]
- self._task_mtimes = item['mtimes']
- changed = self.is_changed(path, item['ignore'])
+ self._task_mtimes = item["mtimes"]
+ changed = self.is_changed(path, item["ignore"])
if changed:
- func = item['func']
- delay = item['delay']
+ func = item["func"]
+ delay = item["delay"]
if delay and isinstance(delay, float):
delays.add(delay)
if func:
- name = getattr(func, 'name', None)
+ name = getattr(func, "name", None)
if not name:
- name = getattr(func, '__name__', 'anonymous')
- logger.info(
- f"Running task: {name} (delay: {delay})")
- if len(signature(func).parameters) > 0 and isinstance(changed, list):
+ name = getattr(func, "__name__", "anonymous")
+ logger.info(f"Running task: {name} (delay: {delay})")
+ if len(signature(func).parameters) > 0 and isinstance(
+ changed, list
+ ):
func(changed)
else:
func()
@@ -222,15 +224,17 @@ def start(self, callback):
self.callback = callback
from tornado import ioloop
+
self.notifier = pyinotify.TornadoAsyncNotifier(
- self.wm, ioloop.IOLoop.instance(),
- default_proc_fun=self.inotify_event
+ self.wm,
+ ioloop.IOLoop.instance(),
+ default_proc_fun=self.inotify_event,
)
callback()
return True
def get_watcher_class():
- if pyinotify is None or not hasattr(pyinotify, 'TornadoAsyncNotifier'):
+ if pyinotify is None or not hasattr(pyinotify, "TornadoAsyncNotifier"):
return Watcher
return INotifyWatcher
diff --git a/tests/test_server.py b/tests/test_server.py
index 152d3fb..2b9d6fc 100644
--- a/tests/test_server.py
+++ b/tests/test_server.py
@@ -7,11 +7,7 @@ class TestInjectScriptAtHead(unittest.TestCase):
def test_injects_before_mixed_case_head_end(self):
content = b"x"
script = b""
- expected = (
- b"x"
- + script
- + b""
- )
+ expected = b"x" + script + b""
assert inject_script_at_head(content, script) == expected
def test_injects_only_first_head_end(self):
diff --git a/tests/test_watcher.py b/tests/test_watcher.py
index 07ee21c..d8cd847 100644
--- a/tests/test_watcher.py
+++ b/tests/test_watcher.py
@@ -1,18 +1,16 @@
-#!/usr/bin/python
-
import os
-import time
import shutil
+import time
import unittest
+
from livereload.watcher import get_watcher_class
Watcher = get_watcher_class()
-tmpdir = os.path.join(os.path.dirname(__file__), 'tmp')
+tmpdir = os.path.join(os.path.dirname(__file__), "tmp")
class TestWatcher(unittest.TestCase):
-
def setUp(self):
if os.path.isdir(tmpdir):
shutil.rmtree(tmpdir)
@@ -22,10 +20,10 @@ def tearDown(self):
shutil.rmtree(tmpdir)
def test_watch_dir(self):
- os.mkdir(os.path.join(tmpdir, '.git'))
- os.mkdir(os.path.join(tmpdir, '.hg'))
- os.mkdir(os.path.join(tmpdir, '.svn'))
- os.mkdir(os.path.join(tmpdir, '.cvs'))
+ os.mkdir(os.path.join(tmpdir, ".git"))
+ os.mkdir(os.path.join(tmpdir, ".hg"))
+ os.mkdir(os.path.join(tmpdir, ".svn"))
+ os.mkdir(os.path.join(tmpdir, ".cvs"))
watcher = Watcher()
watcher.watch(tmpdir)
@@ -35,10 +33,10 @@ def test_watch_dir(self):
# TODO: This doesn't seem necessary; test passes without it
time.sleep(1)
- filepath = os.path.join(tmpdir, 'foo')
+ filepath = os.path.join(tmpdir, "foo")
- with open(filepath, 'w') as f:
- f.write('')
+ with open(filepath, "w") as f:
+ f.write("")
assert watcher.is_changed(tmpdir)
assert watcher.is_changed(tmpdir) is False
@@ -55,9 +53,9 @@ def test_watch_file(self):
# TODO: This doesn't seem necessary; test passes without it
time.sleep(1)
- filepath = os.path.join(tmpdir, 'foo')
- with open(filepath, 'w') as f:
- f.write('')
+ filepath = os.path.join(tmpdir, "foo")
+ with open(filepath, "w") as f:
+ f.write("")
def add_count():
watcher.count += 1
@@ -70,8 +68,8 @@ def add_count():
# TODO: This doesn't seem necessary; test passes without it
time.sleep(1)
- with open(filepath, 'w') as f:
- f.write('')
+ with open(filepath, "w") as f:
+ f.write("")
abs_filepath = os.path.abspath(filepath)
assert watcher.examine() == (abs_filepath, None)
@@ -85,18 +83,18 @@ def add_count():
def test_watch_glob(self):
watcher = Watcher()
- watcher.watch(tmpdir + '/*')
+ watcher.watch(tmpdir + "/*")
assert watcher.examine() == (None, None)
- with open(os.path.join(tmpdir, 'foo.pyc'), 'w') as f:
- f.write('')
+ with open(os.path.join(tmpdir, "foo.pyc"), "w") as f:
+ f.write("")
assert watcher.examine() == (None, None)
- filepath = os.path.join(tmpdir, 'foo')
+ filepath = os.path.join(tmpdir, "foo")
- with open(filepath, 'w') as f:
- f.write('')
+ with open(filepath, "w") as f:
+ f.write("")
abs_filepath = os.path.abspath(filepath)
assert watcher.examine() == (abs_filepath, None)
@@ -108,17 +106,17 @@ def test_watch_glob(self):
def test_watch_ignore(self):
watcher = Watcher()
- watcher.watch(tmpdir + '/*', ignore=lambda o: o.endswith('.ignore'))
+ watcher.watch(tmpdir + "/*", ignore=lambda o: o.endswith(".ignore"))
assert watcher.examine() == (None, None)
- with open(os.path.join(tmpdir, 'foo.ignore'), 'w') as f:
- f.write('')
+ with open(os.path.join(tmpdir, "foo.ignore"), "w") as f:
+ f.write("")
assert watcher.examine() == (None, None)
def test_watch_multiple_dirs(self):
- first_dir = os.path.join(tmpdir, 'first')
- second_dir = os.path.join(tmpdir, 'second')
+ first_dir = os.path.join(tmpdir, "first")
+ second_dir = os.path.join(tmpdir, "second")
watcher = Watcher()
@@ -126,9 +124,9 @@ def test_watch_multiple_dirs(self):
watcher.watch(first_dir)
assert watcher.examine() == (None, None)
- first_path = os.path.join(first_dir, 'foo')
- with open(first_path, 'w') as f:
- f.write('')
+ first_path = os.path.join(first_dir, "foo")
+ with open(first_path, "w") as f:
+ f.write("")
assert watcher.examine() == (first_path, None)
assert watcher.examine() == (None, None)
@@ -136,14 +134,14 @@ def test_watch_multiple_dirs(self):
watcher.watch(second_dir)
assert watcher.examine() == (None, None)
- second_path = os.path.join(second_dir, 'bar')
- with open(second_path, 'w') as f:
- f.write('')
+ second_path = os.path.join(second_dir, "bar")
+ with open(second_path, "w") as f:
+ f.write("")
assert watcher.examine() == (second_path, None)
assert watcher.examine() == (None, None)
- with open(first_path, 'a') as f:
- f.write('foo')
+ with open(first_path, "a") as f:
+ f.write("foo")
assert watcher.examine() == (first_path, None)
assert watcher.examine() == (None, None)
diff --git a/tombi.toml b/tombi.toml
new file mode 100644
index 0000000..dc37d0b
--- /dev/null
+++ b/tombi.toml
@@ -0,0 +1,2 @@
+[format.rules]
+line-width = 80
diff --git a/uv.lock b/uv.lock
new file mode 100644
index 0000000..e1416e4
--- /dev/null
+++ b/uv.lock
@@ -0,0 +1,389 @@
+version = 1
+revision = 5
+requires-python = ">=3.10"
+resolution-markers = [
+ "python_full_version >= '3.12'",
+ "python_full_version == '3.11.*'",
+ "python_full_version < '3.11'",
+]
+
+[[package]]
+name = "asgiref"
+version = "3.12.1"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "typing-extensions", marker = "python_full_version < '3.11'" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/e6/26/3b59f2bdae5f640389becb1f673cded775287f5fc4f816309d9ca9a3f93d/asgiref-3.12.1.tar.gz", hash = "sha256:59dcb51c272ad209d59bed5708a64a333083e86017d7fcdd67498eeab7784340", size = 42378, upload-time = "2026-07-14T09:56:18.087Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/c0/1b/54f4ad77cd8a584fa70746c47df988e002cf1ee1eba43364d46f87803647/asgiref-3.12.1-py3-none-any.whl", hash = "sha256:fe386d1c2bff7259ea95929266d12a8cf9a8b5a1c2598402967d8792e7a7c094", size = 25478, upload-time = "2026-07-14T09:56:16.926Z" },
+]
+
+[[package]]
+name = "colorama"
+version = "0.4.6"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" },
+]
+
+[[package]]
+name = "django"
+version = "5.2.18"
+source = { registry = "https://pypi.org/simple" }
+resolution-markers = [
+ "python_full_version == '3.11.*'",
+ "python_full_version < '3.11'",
+]
+dependencies = [
+ { name = "asgiref" },
+ { name = "sqlparse" },
+ { name = "tzdata", marker = "sys_platform == 'win32'" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/85/fe/79e692b430c7721bf4b812c328bfe687e73e3f28f56611a6a18a97e887cc/django-5.2.18.tar.gz", hash = "sha256:461c5dd06d2ea16bd5ca37d3f46e4def1d6b0fe7588c6f4e2119517bb0af8b2d", size = 10910238, upload-time = "2026-10-06T13:01:09.371Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/d6/01/6568ec52b26548ca1b83d33249f7274db3bf74698f42c0da42ec9db1a24a/django-5.2.18-py3-none-any.whl", hash = "sha256:92ed81d500be6408ecd704d7bd1366c534f30427bffcc63c5fefb129561aec7c", size = 8316071, upload-time = "2026-10-06T13:01:04.413Z" },
+]
+
+[[package]]
+name = "django"
+version = "6.1.2"
+source = { registry = "https://pypi.org/simple" }
+resolution-markers = [
+ "python_full_version >= '3.12'",
+]
+dependencies = [
+ { name = "asgiref" },
+ { name = "sqlparse" },
+ { name = "tzdata", marker = "sys_platform == 'win32'" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/89/24/20d187a22ac821288b53c53b8f696019dcb0c7b4f973f052ade19392c74d/django-6.1.2.tar.gz", hash = "sha256:a1e92451ccb8b514e91bbb3b6d186d20b4030558f116b5d9de6535455ff210b7", size = 11251425, upload-time = "2026-10-06T12:53:29.622Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/c9/83/90ff2dbfac7b519ed77620bdbb827cd2d1a83faf00d076bf95056428bce7/django-6.1.2-py3-none-any.whl", hash = "sha256:141efee6ec64d1db6db90683bf734c550102450f444fb099063b0be1bd27d991", size = 8420684, upload-time = "2026-10-06T12:53:17.381Z" },
+]
+
+[[package]]
+name = "django-stubs"
+version = "6.0.9"
+source = { registry = "https://pypi.org/simple" }
+resolution-markers = [
+ "python_full_version < '3.11'",
+]
+dependencies = [
+ { name = "django", version = "5.2.18", source = { registry = "https://pypi.org/simple" } },
+ { name = "django-stubs-ext", version = "6.0.9", source = { registry = "https://pypi.org/simple" } },
+ { name = "tomli" },
+ { name = "types-pyyaml" },
+ { name = "typing-extensions" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/8b/8f/f761e2d1cdbfce2a10fba4b2d170657cbf54592d5b280823f56f896ffcc0/django_stubs-6.0.9.tar.gz", hash = "sha256:ef9928e900a9d226162b579b50c035d08755f6279fd47de9f427de4123c23623", size = 288505, upload-time = "2026-08-07T12:58:01.987Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/08/7a/c3ee425478eafe63a568bf76015524a25e9b889f29694019551209bd4daf/django_stubs-6.0.9-py3-none-any.whl", hash = "sha256:dbee54721197b453708b39bb6b5988b6543eddf3e5073b8772c13bf4652af17d", size = 549598, upload-time = "2026-08-07T12:57:59.9Z" },
+]
+
+[[package]]
+name = "django-stubs"
+version = "6.1.2"
+source = { registry = "https://pypi.org/simple" }
+resolution-markers = [
+ "python_full_version >= '3.12'",
+ "python_full_version == '3.11.*'",
+]
+dependencies = [
+ { name = "django", version = "5.2.18", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" },
+ { name = "django", version = "6.1.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" },
+ { name = "django-stubs-ext", version = "6.1.2", source = { registry = "https://pypi.org/simple" } },
+ { name = "types-pyyaml" },
+ { name = "typing-extensions" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/56/d0/00a9af43bf65ee54706e29b1c41b3a00d7a97157fbf97ed3038dcc73d584/django_stubs-6.1.2.tar.gz", hash = "sha256:d62b15fa5cbae79b1922d015e1454d299959d230221dadb23f65c749480b97ec", size = 295860, upload-time = "2026-10-05T06:47:17.595Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/96/36/206be00ba6aaf21c72cf61fd38cee492c7dcf51eac72fd61a9f7ddd0f916/django_stubs-6.1.2-py3-none-any.whl", hash = "sha256:ffb6e74f54b8c1f9b2f3bc4852c5abbaa2324b2a7cb1c51854c5f95abe67165c", size = 559846, upload-time = "2026-10-05T06:47:15.889Z" },
+]
+
+[[package]]
+name = "django-stubs-ext"
+version = "6.0.9"
+source = { registry = "https://pypi.org/simple" }
+resolution-markers = [
+ "python_full_version < '3.11'",
+]
+dependencies = [
+ { name = "django", version = "5.2.18", source = { registry = "https://pypi.org/simple" } },
+ { name = "typing-extensions" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/1e/1f/7a4c5f1c1b1b6573623c91c247dfe1f226ed14fa0690e218842bfb5ff88a/django_stubs_ext-6.0.9.tar.gz", hash = "sha256:6777de6d0a059f55ea1d53839de84c2ae96eae131b75fd00c3ee8f6678d9f6d7", size = 6869, upload-time = "2026-08-07T12:57:19.694Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/03/2b/989757760d725417e6131366a56f5cce88370885b6f2e064548bdc9c2cc2/django_stubs_ext-6.0.9-py3-none-any.whl", hash = "sha256:267cb4d0bc87a6c2a0edd0f0dfa59696efd3d21a86b1d346b502fe13c6ea28ef", size = 10419, upload-time = "2026-08-07T12:57:18.46Z" },
+]
+
+[[package]]
+name = "django-stubs-ext"
+version = "6.1.2"
+source = { registry = "https://pypi.org/simple" }
+resolution-markers = [
+ "python_full_version >= '3.12'",
+ "python_full_version == '3.11.*'",
+]
+dependencies = [
+ { name = "django", version = "5.2.18", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" },
+ { name = "django", version = "6.1.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" },
+ { name = "typing-extensions" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/d9/62/de532d58736d671b9ac0c0467909a9429758ae579d636e868ce74519566b/django_stubs_ext-6.1.2.tar.gz", hash = "sha256:2142da7fffbbe897ccecf9b4c97e09ea1a2e9291760b0a824b4c402b375ce6f9", size = 7221, upload-time = "2026-10-05T06:46:43.717Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/21/bb/393c3c1e24aaf37d62918ea50f3ea22e30d5f3162fc62f4c3bf3275e5ed3/django_stubs_ext-6.1.2-py3-none-any.whl", hash = "sha256:7334e687ab6dc78a6c3da90ab1ccb0e412efd827ea11de8d3f85adbad948abb1", size = 10850, upload-time = "2026-10-05T06:46:42.647Z" },
+]
+
+[[package]]
+name = "exceptiongroup"
+version = "1.3.1"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "typing-extensions" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/50/79/66800aadf48771f6b62f7eb014e352e5d06856655206165d775e675a02c9/exceptiongroup-1.3.1.tar.gz", hash = "sha256:8b412432c6055b0b7d14c310000ae93352ed6754f70fa8f7c34141f91c4e3219", size = 30371, upload-time = "2025-11-21T23:01:54.787Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/8a/0e/97c33bf5009bdbac74fd2beace167cab3f978feb69cc36f1ef79360d6c4e/exceptiongroup-1.3.1-py3-none-any.whl", hash = "sha256:a7a39a3bd276781e98394987d3a5701d0c4edffb633bb7a5144577f82c773598", size = 16740, upload-time = "2025-11-21T23:01:53.443Z" },
+]
+
+[[package]]
+name = "iniconfig"
+version = "2.3.1"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/01/e1/2069291243c926a2ff1cd706c7f3eeb9b62144bf60f77c9fb9ff2fb26bd3/iniconfig-2.3.1.tar.gz", hash = "sha256:67f4b9c50da0dedf52af349e7749a80a9057a5031199791b906c3bb3ae878960", size = 21209, upload-time = "2026-10-06T22:48:38.076Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/56/43/4ca9e49d27a1fcf6bece6f6aec0ea46bb9112489b93d4b688fb415457bdb/iniconfig-2.3.1-py3-none-any.whl", hash = "sha256:9121e2c1fdb355232495be3194c8dfe87ccc2d5dee45947b78e68f499790d7a7", size = 7552, upload-time = "2026-10-06T22:48:36.959Z" },
+]
+
+[[package]]
+name = "livereload"
+source = { editable = "." }
+dependencies = [
+ { name = "tornado" },
+ { name = "typing-extensions", marker = "python_full_version < '3.12'" },
+]
+
+[package.optional-dependencies]
+notify = [
+ { name = "pyinotify", marker = "python_full_version < '3.12'" },
+]
+
+[package.dev-dependencies]
+dev = [
+ { name = "django-stubs", version = "6.0.9", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" },
+ { name = "django-stubs", version = "6.1.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" },
+ { name = "prek" },
+ { name = "pytest" },
+ { name = "typing-extensions" },
+]
+
+[package.metadata]
+requires-dist = [
+ { name = "pyinotify", marker = "python_full_version < '3.12' and extra == 'notify'", specifier = ">=0.9.6" },
+ { name = "tornado", specifier = ">=6.1" },
+ { name = "typing-extensions", marker = "python_full_version < '3.12'", specifier = ">=4.4" },
+]
+provides-extras = ["notify"]
+
+[package.metadata.requires-dev]
+dev = [
+ { name = "django-stubs", specifier = ">=6.0.9" },
+ { name = "prek", specifier = ">=0.5.5" },
+ { name = "pytest", specifier = ">=8.4.2" },
+ { name = "typing-extensions", specifier = ">=4.4" },
+]
+
+[[package]]
+name = "packaging"
+version = "26.3"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/7d/fa/3944b40b07da9ce895c0e6303a5ab7d53da063554f534556b134a54d6093/packaging-26.3.tar.gz", hash = "sha256:94edc256424af38762eb31306eed28beb9f0efc50a8837492c9d6fd6004aed79", size = 313412, upload-time = "2026-08-04T18:15:28.737Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/63/34/ba1c580383c9eada3711951fef0795c80b829a078d72188184bcab9dd527/packaging-26.3-py3-none-any.whl", hash = "sha256:d7193f7c8e4e93f444fde0262bf90af30e16fa0ad0ad44cb553c87339b23cd1c", size = 129956, upload-time = "2026-08-04T18:15:27.159Z" },
+]
+
+[[package]]
+name = "pluggy"
+version = "1.7.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/bf/db/7fc19e6f2dc92a966727031389fc2e08b558f0f25eb7403c1119ad4713cd/pluggy-1.7.0.tar.gz", hash = "sha256:d1eaa46ebb595891b860ab086b4d09c8588af65ebd4361b8e8f4bb8920b90ba8", size = 123304, upload-time = "2026-10-15T09:50:58.343Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/40/9e/2b38731e0fc536806f16490e1a12d7f0dc2a1235aa8cc07bcc75416a7daa/pluggy-1.7.0-py3-none-any.whl", hash = "sha256:7dd7b0d8832ba3cb632c306926ded123429211b83641b35dc5c41ad2d34f9bec", size = 27082, upload-time = "2026-10-15T09:50:56.808Z" },
+]
+
+[[package]]
+name = "prek"
+version = "0.5.5"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/9b/7b/11538b8e6cd7e269261feae62a2d08818fdcf8f43c3b1a253cec01a5488d/prek-0.5.5.tar.gz", hash = "sha256:16bf596768bb754779c7d42317e9ca16ac609c0cce7d13bae0829944ab40a26a", size = 570428, upload-time = "2026-10-05T06:08:59.165Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/2e/77/189e554ea92baf6c1d33733b3e9d1ed819ab7d77e57eb2e3ead0bb1d8060/prek-0.5.5-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:50fbceb0f728b504023f6e6c9037ac0656436949d94f7a85bac5542e6c1a4a75", size = 6038669, upload-time = "2026-10-05T06:08:39.368Z" },
+ { url = "https://files.pythonhosted.org/packages/63/ea/fc857c6b3f1e3fdb4d81bdca2051076de82e986cb1455a01b351ac7a5cd1/prek-0.5.5-py3-none-macosx_11_0_arm64.whl", hash = "sha256:2f610927b95e84b28173a4c810892a7b5d5bb55bee792ddf97178fa97654633e", size = 5548955, upload-time = "2026-10-05T06:08:41.428Z" },
+ { url = "https://files.pythonhosted.org/packages/47/c3/2bab54f6393fb9f247225681d476086e24fa6242f329e9ca1e5bd638ec50/prek-0.5.5-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.musllinux_1_1_aarch64.whl", hash = "sha256:120ef7d4b6bb58155e74827743c266e48501efb0e4c37ff726b14fe6e86a126b", size = 5848228, upload-time = "2026-10-05T06:08:44.051Z" },
+ { url = "https://files.pythonhosted.org/packages/aa/f9/1279e1c83819de5904db1a09f4afc589932a2e62c7063713b1fe3a54ee46/prek-0.5.5-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:2f6c453a12c21757b23bbbc97415aef78527841ad3f9e61f52cb35cf012f93e5", size = 6371296, upload-time = "2026-10-05T06:08:46.278Z" },
+ { url = "https://files.pythonhosted.org/packages/93/fc/e566be5919eaffef4cc22488f16e4bbfd08961734bd67c06f85d405e4cbc/prek-0.5.5-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:73d509c0d9556171de2502bada4962be7a25871e7b23e782549074e1416dc850", size = 6269922, upload-time = "2026-10-05T06:08:48.645Z" },
+ { url = "https://files.pythonhosted.org/packages/d2/39/a48d8e6203055bff7bb734b1f2f9aede4a4539bee6ab4c9273dbc01fa546/prek-0.5.5-py3-none-manylinux_2_28_aarch64.whl", hash = "sha256:76c867fdbd4e4f19e6b6b2988bd1e1407d1324170ebcf1428e4e34f8f0751a99", size = 5861518, upload-time = "2026-10-05T06:08:50.808Z" },
+ { url = "https://files.pythonhosted.org/packages/b7/03/690fba9543033e25ea2e7b2ae1cca1ae8d8c2e32295f0d66f4b50934b8c7/prek-0.5.5-py3-none-musllinux_1_1_x86_64.whl", hash = "sha256:c69f60c2b3b4d481e38d0eab3b97ac05ec05d7648ae690925ae95ca9d22c7643", size = 6383685, upload-time = "2026-10-05T06:08:52.868Z" },
+ { url = "https://files.pythonhosted.org/packages/8c/04/1e9ccf3a61a3bfd4107b83973f7afde9177b6ff8f2d5d4b00125e77c2100/prek-0.5.5-py3-none-win_amd64.whl", hash = "sha256:15bff9039349466ea9bf6b0c173b822ff08f8ae4cfbfdea782492e519c06d874", size = 5784759, upload-time = "2026-10-05T06:08:55.056Z" },
+ { url = "https://files.pythonhosted.org/packages/41/d9/bb6811e438d3c74d26cb5d72e6343606e0a7a6283fee87521189f4ba1191/prek-0.5.5-py3-none-win_arm64.whl", hash = "sha256:3db3191d2509830c75438e20956a47c2259867d18a4106064ed75088b968a0c6", size = 5517355, upload-time = "2026-10-05T06:08:57.193Z" },
+]
+
+[[package]]
+name = "pygments"
+version = "2.21.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/49/2e/ced460408999b33da6b31b0021b0f37d329e202d4169aeb164493778f25b/pygments-2.21.0.tar.gz", hash = "sha256:610ca751c9bc2492b38eb9a38a7fbc93edbbb2d7182edaf34e66ae493dee5c8c", size = 5005329, upload-time = "2026-08-17T08:02:48.824Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/71/46/17f022dd3e953bf20a04a028a21ec746d942f8d2af30fa0f124fa0e6a684/pygments-2.21.0-py3-none-any.whl", hash = "sha256:2363c69b61c4a97c838da3b130dcd6468f4848992b21a82f2a63ec34377137d9", size = 1250147, upload-time = "2026-08-17T08:02:44.912Z" },
+]
+
+[[package]]
+name = "pyinotify"
+version = "0.9.6"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/e3/c0/fd5b18dde17c1249658521f69598f3252f11d9d7a980c5be8619970646e1/pyinotify-0.9.6.tar.gz", hash = "sha256:9c998a5d7606ca835065cdabc013ae6c66eb9ea76a00a1e3bc6e0cfe2b4f71f4", size = 60998, upload-time = "2015-06-04T15:50:23.633Z" }
+
+[[package]]
+name = "pytest"
+version = "9.1.1"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "colorama", marker = "sys_platform == 'win32'" },
+ { name = "exceptiongroup", marker = "python_full_version < '3.11'" },
+ { name = "iniconfig" },
+ { name = "packaging" },
+ { name = "pluggy" },
+ { name = "pygments" },
+ { name = "tomli", marker = "python_full_version < '3.11'" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/e4/47/b9efed96c114afcfa3c9d3fe98a76a1d14c74a9e266d397cf6eb64be5e01/pytest-9.1.1.tar.gz", hash = "sha256:1088fbde8f2b49d95a549a195707afa7a76a3ce9bcadc26b6d71f0ffda5fe313", size = 1636369, upload-time = "2026-06-19T10:58:32.857Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/24/25/1de2678b631f5a49215c6c96fff41ba892b0a34df68d6d80292b1b48aa7f/pytest-9.1.1-py3-none-any.whl", hash = "sha256:37a86b45efb9a47a61a36449063e8e18d0cab3161329fc099eb21783169c4f0c", size = 386536, upload-time = "2026-06-19T10:58:31.347Z" },
+]
+
+[[package]]
+name = "sqlparse"
+version = "0.6.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/5f/d3/3f06a1006f2261d1342aefb3c71eed02f5d4ca5bdbecd86ebc12ad38306e/sqlparse-0.6.0.tar.gz", hash = "sha256:113c35c75365ab9cc9c7231d68c6428fb11c085fc8e9eb1ad659b7ddbf6cd2b9", size = 178477, upload-time = "2026-08-13T19:16:06.396Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/d9/50/f00935da0ec7cbf325f8dc4f772ae46fbc7b672dd62876e73f0a94adda57/sqlparse-0.6.0-py3-none-any.whl", hash = "sha256:b861c0288ce2fa56209a9a6412d2e066ac664b3873b89c26c9d8415e8e32996f", size = 50070, upload-time = "2026-08-13T19:16:04.062Z" },
+]
+
+[[package]]
+name = "tomli"
+version = "2.5.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/b0/78/9ad63712633ed3ab5cc1a648d863d7e7da371e9425e209555a0fe711b695/tomli-2.5.0.tar.gz", hash = "sha256:264507556cd8b8c8e7c6ee037cdf443a463f03f4c958e57195e3d369711b8ff6", size = 17662, upload-time = "2026-10-07T12:23:37.892Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/22/a6/ab99b60ee52acd949684febabc3005d0045d0f66bebd9cdebd67372d26dd/tomli-2.5.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:c4dc1c1781f2f716de763d1e9a7b34c6a894e167e291c7c5d16c72f7a9538545", size = 163901, upload-time = "2026-10-07T12:22:15.601Z" },
+ { url = "https://files.pythonhosted.org/packages/bc/00/ee01b7ed4579180fff07142d290257f25ba786f23f3ec6005f620933c2f5/tomli-2.5.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:eff8babca5a7999bc137acbc7482a8b7e17ffca5075ab41f5d770ab408c7bfef", size = 163756, upload-time = "2026-10-07T12:22:16.957Z" },
+ { url = "https://files.pythonhosted.org/packages/72/c2/4efebf65372f6583185f79799312109dddb61102d47e5c33dcfd1a297aca/tomli-2.5.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:86665cee9c4835b7a7f1e8ec2c719b5258d4dc782887aded5a8ae7352a96843b", size = 268038, upload-time = "2026-10-07T12:22:18.135Z" },
+ { url = "https://files.pythonhosted.org/packages/53/07/5850468e925d898abb36038666f9c333a94d2a223e802a8ba5b6d319d23f/tomli-2.5.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d7e369fd63331746182360977b1892bfc215476a30d61612d732425311639f56", size = 276422, upload-time = "2026-10-07T12:22:19.567Z" },
+ { url = "https://files.pythonhosted.org/packages/b4/87/f293984cdcf83c054196d4fd3dad44fc68ae55b4b8c44bc76cef360c3150/tomli-2.5.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:7ad1ea345759240d6463efa0ed1c704402752e49aa21476620738d74d72d8aa1", size = 272616, upload-time = "2026-10-07T12:22:20.794Z" },
+ { url = "https://files.pythonhosted.org/packages/ce/ce/db582886b3c1219d3fec93ebd669332482e5aee7a91e0f7838d84f2d1759/tomli-2.5.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:96243987194634bd411066ce40c952e108f86af04db533ecd8ac3ff2a85b1885", size = 276593, upload-time = "2026-10-07T12:22:22.12Z" },
+ { url = "https://files.pythonhosted.org/packages/bf/72/7619b87dea4261fc27dd7b54c4461c129c1f7d9bb7ba3aec89c797a431b8/tomli-2.5.0-cp311-cp311-win32.whl", hash = "sha256:610b27d99f28ec5f191c7064a48f3ddb179a1fe6ca73d571483ae859f57b605e", size = 101830, upload-time = "2026-10-07T12:22:23.651Z" },
+ { url = "https://files.pythonhosted.org/packages/1e/74/220106da34502304b6751a2a9b8a9fbca6c3fd47e737a2e2e3da7c61c9db/tomli-2.5.0-cp311-cp311-win_amd64.whl", hash = "sha256:c804ae44fe7b4bab5da295e4f980a1ff04670bca9d23fe0a4e887e08ebd741a8", size = 112742, upload-time = "2026-10-07T12:22:24.972Z" },
+ { url = "https://files.pythonhosted.org/packages/27/99/7d9c8b41837a7773613e169504147375c157a290167aa59ad74a085f521f/tomli-2.5.0-cp311-cp311-win_arm64.whl", hash = "sha256:cfac177ebd6236003846ea339981f71457cb6eb748f23381eb257e45092e3980", size = 109332, upload-time = "2026-10-07T12:22:26.117Z" },
+ { url = "https://files.pythonhosted.org/packages/52/ed/7baa86f87493646a594de388c7c1c40a39dd0461f7e9c0359cbeefc91fe8/tomli-2.5.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:1f4a40d03fb9f63424f0979855bdeaf44dd7696b8d59501822c10ed30ba532df", size = 164854, upload-time = "2026-10-07T12:22:27.444Z" },
+ { url = "https://files.pythonhosted.org/packages/a5/b1/44c0341f2224397855723c7a8a39f718ea6fcbcc3dacc66e5aeca0f334e3/tomli-2.5.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:9ebf8d19b17bd0daeb7b7dec81a946a439b753942fd0210d6e96c532249eea6b", size = 164074, upload-time = "2026-10-07T12:22:28.679Z" },
+ { url = "https://files.pythonhosted.org/packages/23/04/e2d5b7d3fba47adedb23de616c16d428ea076c79a3d8e1d95d649ffe197e/tomli-2.5.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bf0b5e8e0f68ebb494356e577c06c139161efd8d3b9050f93b39b7c26cc54ff0", size = 274274, upload-time = "2026-10-07T12:22:29.804Z" },
+ { url = "https://files.pythonhosted.org/packages/43/90/6090e706ff27a6f89f4a40578e3324b95c3cd8c4150868aabf33a8f414c3/tomli-2.5.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6cf74416bdc94ae458b14e37286c1073081850ac8459a00d0c5efef5d44294c6", size = 286435, upload-time = "2026-10-07T12:22:31.297Z" },
+ { url = "https://files.pythonhosted.org/packages/0a/9e/a2c40768df16c408f22430afb0a73e9d7e5f79c950884954649d1146b74d/tomli-2.5.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:61ea1ebe1e55a34ea8199cc8dbff398d35027b82271c8ac4802fd3a1fd5b1bcc", size = 278119, upload-time = "2026-10-07T12:22:32.601Z" },
+ { url = "https://files.pythonhosted.org/packages/12/25/3c0cb485b98e9cfac495629b1c93c87ccf0b72fbe9d2689fd8fe62c6d5a3/tomli-2.5.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:ed53f7e89bb04f6d9e8e7799112360b0c4d5cbff067de0814c98c37c39b920f7", size = 286177, upload-time = "2026-10-07T12:22:33.745Z" },
+ { url = "https://files.pythonhosted.org/packages/77/8b/0144c65f0e37e51c18d04ae15c21b19431c165002d0131fe9aa8b0b8b1e8/tomli-2.5.0-cp312-cp312-win32.whl", hash = "sha256:e7ad033e27a516a233bea839cdb77b80146facb3b4f40bf02cd0cac165cdd5c2", size = 102760, upload-time = "2026-10-07T12:22:34.887Z" },
+ { url = "https://files.pythonhosted.org/packages/de/32/5d6d8f42fc9a05fce69354e00ff256484192f5f2fc9a2165718fa0de61ec/tomli-2.5.0-cp312-cp312-win_amd64.whl", hash = "sha256:bd05de8c1698f8413dd7d869492693a0bf2211543b787ac78cd5e7536af1a6d7", size = 112722, upload-time = "2026-10-07T12:22:36.162Z" },
+ { url = "https://files.pythonhosted.org/packages/30/65/df18032218db0fb9b769fb23c8039a051f15c811993995ea04c350273a32/tomli-2.5.0-cp312-cp312-win_arm64.whl", hash = "sha256:069435bd5480429b98c5e5afb02ab21c219b6f0064680671c6dc0d46817346ea", size = 109534, upload-time = "2026-10-07T12:22:37.296Z" },
+ { url = "https://files.pythonhosted.org/packages/42/e5/51736d70da209350969e15aca5c5ab6e2ce1ea87a0a892a6c13aec172a86/tomli-2.5.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:943276cf269e0071948d9ff697159c1735e623c1151d88abb09b74659ef0cbea", size = 163328, upload-time = "2026-10-07T12:22:38.373Z" },
+ { url = "https://files.pythonhosted.org/packages/ec/55/086f80dab4ab497602644274e6dea7ec5dd0b4e262e443a8ad3bb7edee2d/tomli-2.5.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:463b16086865b97facd8d0b3fb4cb7c544e3f58d2a69dc3113d6db9653fdb043", size = 162246, upload-time = "2026-10-07T12:22:39.673Z" },
+ { url = "https://files.pythonhosted.org/packages/aa/eb/3ecc94459f3635c92321f4e7bde571323fdb2267c50e19e3188a281eae3b/tomli-2.5.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1245a6638fc4bb0a60af38a7d45413db34a13842027c77597c712c998c62fdf0", size = 272655, upload-time = "2026-10-07T12:22:41.08Z" },
+ { url = "https://files.pythonhosted.org/packages/c0/d7/494fd1f0c37a621f1ad9975c2efadb523e8101f144ed6edb2e7fe64738f2/tomli-2.5.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5d8bac3d603c97e6854424e5b2b5b741bdbde387e09f162fb0446812b4a8362b", size = 283595, upload-time = "2026-10-07T12:22:42.222Z" },
+ { url = "https://files.pythonhosted.org/packages/70/51/bb8d62b1317e6640866f6949b2d5855e5300f2c99d46de1cd245570bba65/tomli-2.5.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:21e4cae4114aba25aa0d4f85cdf486d290fb35c0954d7bba536248da64d43066", size = 276253, upload-time = "2026-10-07T12:22:43.625Z" },
+ { url = "https://files.pythonhosted.org/packages/66/f4/f46bd7f0763cd47de2db697dca9257c6a4adfd1a93b018cc75c8190ed5a8/tomli-2.5.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:bbaefc84548d754be821bba7c4141c4787dda182f9e77f2f87b71213529efa7b", size = 283582, upload-time = "2026-10-07T12:22:44.983Z" },
+ { url = "https://files.pythonhosted.org/packages/ac/03/70f2bcb2923a6db37818d917e124270a7f4cfd38ea576f5aa753a91c0ef5/tomli-2.5.0-cp313-cp313-win32.whl", hash = "sha256:abdbf6313b8d9efe157edeb7ab6eae4de064b1300ad31abf73755154b30abe68", size = 102628, upload-time = "2026-10-07T12:22:46.508Z" },
+ { url = "https://files.pythonhosted.org/packages/dc/98/d52024bb5b0ff68b4f0d276d867f634c84a67319a7e9f6b7708a37742333/tomli-2.5.0-cp313-cp313-win_amd64.whl", hash = "sha256:fd4dc129784e0c5335bd4e61dfcc4487499a013419e655cf2da1d091b7e0efdc", size = 113301, upload-time = "2026-10-07T12:22:47.647Z" },
+ { url = "https://files.pythonhosted.org/packages/6f/f2/540db3a70572a8c23a28aba3e9c358ce0ffffbafc990905c1343aa265b31/tomli-2.5.0-cp313-cp313-win_arm64.whl", hash = "sha256:69491c143d2fe063046e0301e62a810bed338fa4d1ce0fd870c27dc1e09b0d84", size = 109744, upload-time = "2026-10-07T12:22:48.925Z" },
+ { url = "https://files.pythonhosted.org/packages/e4/49/caf6b307766eb9567664a8707e9d6be5fcc0e8903f18781c6677a60d80c7/tomli-2.5.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:d3182ee2d887e507bd67319a0a61105d1dd33facc111329559a233b772c1a105", size = 162899, upload-time = "2026-10-07T12:22:50.088Z" },
+ { url = "https://files.pythonhosted.org/packages/d3/c8/68cfce773a2733a49c74f99d627fb461bd990756860099eac25617889585/tomli-2.5.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:521345fd1f19d45b8df87657aaa38b6f2ca3800059fadf428e7ebf479a383646", size = 162080, upload-time = "2026-10-07T12:22:51.558Z" },
+ { url = "https://files.pythonhosted.org/packages/7e/b2/e5bb8651fdad593f670501a7d718b1a7f73f064d44dea15e04c04dfef45d/tomli-2.5.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6e95c7614e705bfe2b04b27aa124adec59752d15813df37e2156747cab3a006b", size = 273380, upload-time = "2026-10-07T12:22:52.918Z" },
+ { url = "https://files.pythonhosted.org/packages/8d/d2/9e2d7f8b1dfe0e2b34c245986ebd55c4c553ea4ce6c47c443b332673253f/tomli-2.5.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7ac2027d37c3afbdf4bdd377f2676f6f1d2122a5be1f1137b49dced590b37e75", size = 283228, upload-time = "2026-10-07T12:22:54.173Z" },
+ { url = "https://files.pythonhosted.org/packages/ba/df/ec7b876b7b1a2718bd74a3743c076fff565b04029ba33e8f61fac262739f/tomli-2.5.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:c414be4ed9d3cac80c42e348fa5a956117d1a48227f48026e31f59cb4a7671eb", size = 277189, upload-time = "2026-10-07T12:22:55.342Z" },
+ { url = "https://files.pythonhosted.org/packages/7d/7b/e192d9eed0b9cb80da799f4d77052297fb9a2c3cc9b19f571f56ea88add6/tomli-2.5.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:9b03d7dc168353b4132965bde20feceabaa470e570c6f59660dfae59b1f9eeb3", size = 283632, upload-time = "2026-10-07T12:22:56.735Z" },
+ { url = "https://files.pythonhosted.org/packages/84/50/ff94454e75461d75623e47401ed323d65c10aab8fe9033242c20cd2fdf32/tomli-2.5.0-cp314-cp314-win32.whl", hash = "sha256:6f041843c4d3a37245c0c056fd955b186bf8b1fb85690cbe40b81230891dc34b", size = 103535, upload-time = "2026-10-07T12:22:58.084Z" },
+ { url = "https://files.pythonhosted.org/packages/54/0b/bdacf05f963bd6026ebf6eeb0beda847d1d60e03e440725c64a4e08a0afd/tomli-2.5.0-cp314-cp314-win_amd64.whl", hash = "sha256:f4b653094e18f9031102d3a1da5c729c8f222d85225b18037dac621695e46e1a", size = 114621, upload-time = "2026-10-07T12:22:59.2Z" },
+ { url = "https://files.pythonhosted.org/packages/61/99/53f438fa6ae4f9d4ed0ddde3e7242b3bdc34b48c8f9948b72b9e9b127676/tomli-2.5.0-cp314-cp314-win_arm64.whl", hash = "sha256:3f89d10c1ff6a38d992c27fc8a4816af71a909e08a40ec66934240b1e74347c3", size = 111572, upload-time = "2026-10-07T12:23:00.479Z" },
+ { url = "https://files.pythonhosted.org/packages/b9/20/1f88f19427d380a40e90a770e087489eaafe4aeee070ae88ed2bbec00acd/tomli-2.5.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:e9e15b4a6c7dd6b85b5fbab29488a73f1f70de516942308daa266bf0e0aeb0d4", size = 171814, upload-time = "2026-10-07T12:23:01.914Z" },
+ { url = "https://files.pythonhosted.org/packages/d0/56/cbe5079c9f9a54b9b3e27fc82f08f3cb36edee75561679f53d2380c801d6/tomli-2.5.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:e12bbcd32897272fb05929110362ae9ff4c1b9bb26bd9e971e71dcd3275b4c3d", size = 171324, upload-time = "2026-10-07T12:23:03.18Z" },
+ { url = "https://files.pythonhosted.org/packages/2b/30/1d53fd3b0f1cb3ba542e345ec32c26aefdddc4e829e4f3429af8a4f27782/tomli-2.5.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:20aa36de8f2cf87237143bc1fa1aae8d6612c09118f4da21c6a684db5dd1f6f9", size = 297441, upload-time = "2026-10-07T12:23:04.345Z" },
+ { url = "https://files.pythonhosted.org/packages/66/d9/0800acb6a111686f764c1b91ef15cc42a20a66a46013bb42220f1d2c61c1/tomli-2.5.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:22185fad8a1e622f064e78008018a0dd3323550dcb479cb7a1d296888d74024f", size = 307476, upload-time = "2026-10-07T12:23:05.671Z" },
+ { url = "https://files.pythonhosted.org/packages/e8/63/30a8f3cd51b5bec37f04744bad0b0dc6160df84aad4f27b0e9283d66f221/tomli-2.5.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:984012f71908165449a951de2050d52f276bfe3aa5d5f570f63ddad814370374", size = 296113, upload-time = "2026-10-07T12:23:07.202Z" },
+ { url = "https://files.pythonhosted.org/packages/ab/18/0b9ffc597e69c5a1e20a7823cb60d54b39a9f54e91edcb8574f022186758/tomli-2.5.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:f79203b3965b4000e91808aaa7c040206093f2b8bf86f455982f2274c9ccf442", size = 307725, upload-time = "2026-10-07T12:23:08.508Z" },
+ { url = "https://files.pythonhosted.org/packages/ab/c7/18f8baae0b5607a60e8e19b4a7fedee43a8ff6458e3896dcbbadeeac9c22/tomli-2.5.0-cp314-cp314t-win32.whl", hash = "sha256:91294a9fb94a75542f6e46e4a2ae709bd8d9b51134098cae5cf3bea5478b6d03", size = 108546, upload-time = "2026-10-07T12:23:09.956Z" },
+ { url = "https://files.pythonhosted.org/packages/72/34/4cca9739254130627bde87500b3f2b512154fe2f278efa7e2a5e10ad4bcb/tomli-2.5.0-cp314-cp314t-win_amd64.whl", hash = "sha256:f15e3e0b835a6d68b10c86bf80a3149780498d6911c93c3ffd1861d19f9200f1", size = 117814, upload-time = "2026-10-07T12:23:11.486Z" },
+ { url = "https://files.pythonhosted.org/packages/7d/fb/afa530d47dd80a78fce43beac6bc6e00f84558eafcffbc6f37b21e80d056/tomli-2.5.0-cp314-cp314t-win_arm64.whl", hash = "sha256:6664b7ae7af7294256c53960a6103077f4914cec8ff98479c352f622c6f6b2f0", size = 115188, upload-time = "2026-10-07T12:23:12.728Z" },
+ { url = "https://files.pythonhosted.org/packages/66/98/316fdc00f8c0939e6fe50461dd343c162d3ad51d1286eb25b7db54361d50/tomli-2.5.0-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:a525685c2f97da40762b8695eb7aa0af4c8344ca1905c73e4e29cb04d34607dc", size = 162775, upload-time = "2026-10-07T12:23:13.941Z" },
+ { url = "https://files.pythonhosted.org/packages/c5/22/7b10fa5bb01c9539f53f69b619361b19350acc73657772ea7ac70ba309a8/tomli-2.5.0-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:9dbb18c1cfb2f6517942fc9314437f66aa06d94436ffb1f06102ef3572f35276", size = 161406, upload-time = "2026-10-07T12:23:15.215Z" },
+ { url = "https://files.pythonhosted.org/packages/9c/e7/1a069d86dfd20f1f84f71c63faed9f83c1d890bc06c27d82dc7d888fb573/tomli-2.5.0-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:752e8b1aa6a4367ef8bf6a1a1e005540f7ed055ba36d7193796812ca5404eb52", size = 273855, upload-time = "2026-10-07T12:23:16.471Z" },
+ { url = "https://files.pythonhosted.org/packages/ae/83/d1ef43d1687d092ab9c235455c76e6e709483b346b056f086095c7c263a5/tomli-2.5.0-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c47300f9bf791808f77d82747691c4bb09cb14bdf3060cca99b42cdc4361d5a7", size = 284910, upload-time = "2026-10-07T12:23:18.166Z" },
+ { url = "https://files.pythonhosted.org/packages/cc/05/f4d9cf7de61822ece0c3873f30d291e324911c71a378b8bfe5ced13fd9f5/tomli-2.5.0-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:19b0dd8749f4ea2f112c5fcfb3c5248390c899d7e2e173f1d91abee1fa0ff391", size = 277723, upload-time = "2026-10-07T12:23:19.355Z" },
+ { url = "https://files.pythonhosted.org/packages/42/28/78262493141fa543151cf005760c3cb01d09fc28a11f993c05109902cb8c/tomli-2.5.0-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:57b1c3b01fab802e2899bc3d168dca320e14165e2fd9fd584760fb4ca5826859", size = 285115, upload-time = "2026-10-07T12:23:20.698Z" },
+ { url = "https://files.pythonhosted.org/packages/1a/b9/e1dab9a30bcb677b5cc5cee810609cfd64f24306a3055767dd3fda00b1e0/tomli-2.5.0-cp315-cp315-win32.whl", hash = "sha256:667e521b37a6c5ccaa044202c235b530f90177ffe2cd4a64ecc213c7dd535feb", size = 103475, upload-time = "2026-10-07T12:23:21.941Z" },
+ { url = "https://files.pythonhosted.org/packages/4c/bd/31a3790c11d6ea95fcf5e6022ac0f8d0543c9b61120b730fc481bd43d3b4/tomli-2.5.0-cp315-cp315-win_amd64.whl", hash = "sha256:d747252933c8a65ef6bd8da0fbb7ce28a90eb6119d8cd00772cd528aa07b68d5", size = 114589, upload-time = "2026-10-07T12:23:23.098Z" },
+ { url = "https://files.pythonhosted.org/packages/47/a2/4f6310fa699364f0e3af7ee3af88dddd9af066d33e716a0265bbe2b3ea84/tomli-2.5.0-cp315-cp315-win_arm64.whl", hash = "sha256:75dbcde8751b0a960aa3de173aa5e894d590755c6d7758b7e774c06f1dc3cbdd", size = 111493, upload-time = "2026-10-07T12:23:24.233Z" },
+ { url = "https://files.pythonhosted.org/packages/68/14/00853f0b396d8971107ae1921bb5b322fdee1650d2f16bf06c20adb532e5/tomli-2.5.0-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:2419c2a189551987b59d80e63ec355671283336f41c6b9b89462df679c7d0c57", size = 171380, upload-time = "2026-10-07T12:23:25.512Z" },
+ { url = "https://files.pythonhosted.org/packages/89/ad/fa6949321dadee46b27363974fb197b94c911c3b0f7a5fd26d7dc18fc2a0/tomli-2.5.0-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:0dc598040da8d42cf20f0be588ed7004f46db12a0ac6c32e03a59dccedaaadcd", size = 170553, upload-time = "2026-10-07T12:23:26.855Z" },
+ { url = "https://files.pythonhosted.org/packages/53/aa/3056c919eb3e084df3752b2cf5f865dcc04af0b27dba2f66d7b28af4633a/tomli-2.5.0-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:49096930c8d886c9bbdab62d2d0d17ce823ddeea522309a190b36245d5b49e01", size = 294428, upload-time = "2026-10-07T12:23:28.132Z" },
+ { url = "https://files.pythonhosted.org/packages/96/b2/faeeb5d8769ea3832021d73e892c8391eae7b4b4f8b55a789127bd8b18a9/tomli-2.5.0-cp315-cp315t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b8ade5023067f99fe72b88accd30d0ea05a158e9e32a11f124e731ea9695313f", size = 304909, upload-time = "2026-10-07T12:23:29.381Z" },
+ { url = "https://files.pythonhosted.org/packages/f6/52/f094c09e73fb654b621716d019acb5d29bdfd1be01df80c281d552bda48d/tomli-2.5.0-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:b69564772b5c8f22ea5f498dff08cfa825045b4d4c4400529000bdf818aa3b2a", size = 293220, upload-time = "2026-10-07T12:23:30.608Z" },
+ { url = "https://files.pythonhosted.org/packages/86/f5/0c30541078ca4b505ce3bd76ed931facbfec524dd018535d691d1af0a6d2/tomli-2.5.0-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:8ff3a2ca028c7eee0c777f9a092038d0a594a9fa04e215f929a22c329e2cb142", size = 305705, upload-time = "2026-10-07T12:23:32.181Z" },
+ { url = "https://files.pythonhosted.org/packages/05/74/590e7d19d6a118fc5cc5704ff358e21d95b8573f6b9443b1519f29ca8825/tomli-2.5.0-cp315-cp315t-win32.whl", hash = "sha256:62fc1bc8eb03e3a9cadfca713d65614ed8e09d974a283295ffe3a831976b4dc5", size = 108432, upload-time = "2026-10-07T12:23:33.496Z" },
+ { url = "https://files.pythonhosted.org/packages/1c/b8/63a75cfb27a17c38550e44025d3a6e7be64516fd8608a3b75703bf37d81b/tomli-2.5.0-cp315-cp315t-win_amd64.whl", hash = "sha256:f3fcbc57b1791fa6cbe5d8434179d51de12be1a4811469529f47f6e7487a2571", size = 117281, upload-time = "2026-10-07T12:23:34.648Z" },
+ { url = "https://files.pythonhosted.org/packages/72/01/e8c1debb2173973372934c68fc8e46170ab60ef23ed4592dff4dec6e8993/tomli-2.5.0-cp315-cp315t-win_arm64.whl", hash = "sha256:d2ba24db8a9376921b5e87b4762b9adb0f3f1deaea68f2b8b0bb2c11efb9c3e7", size = 115069, upload-time = "2026-10-07T12:23:35.77Z" },
+ { url = "https://files.pythonhosted.org/packages/60/3f/3e3f8fd0919249b0200c80fbc4f9a1e70be19f9883da71dfb7f8b9ab8aca/tomli-2.5.0-py3-none-any.whl", hash = "sha256:32a7b79ac57a2e83670ce329ccf675798bc5a2094783a63676866b70503f2e2b", size = 14765, upload-time = "2026-10-07T12:23:36.875Z" },
+]
+
+[[package]]
+name = "tornado"
+version = "6.5.10"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/06/61/53d562a57b28c08eda40b258c0f975e360541943ad7c7bef897a40caafda/tornado-6.5.10.tar.gz", hash = "sha256:a6b1ccd08c04b4a06fb5aeb381be99de5ad1e5375c1785e31d78c880feb57687", size = 537910, upload-time = "2026-09-15T13:47:48.73Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/cd/5b/ff5fc58fa2427c30dea74c90053f4fc5eda1e7f3833ed3ecc7147fe2b311/tornado-6.5.10-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:9261783640e23258694a9ff0795df430a5a7b0a651d3dd53dd0969ad6be16da7", size = 465883, upload-time = "2026-09-15T13:47:35.463Z" },
+ { url = "https://files.pythonhosted.org/packages/ad/f5/cd7be26c34a3315532f3aef5f092465da8f59c334dd439d3c14aaef16461/tornado-6.5.10-cp39-abi3-macosx_10_9_x86_64.whl", hash = "sha256:83e6cf438b106c6b3852d70960967bb1b70c87438050dca0981e4b9aa751a4c1", size = 464046, upload-time = "2026-09-15T13:47:37.178Z" },
+ { url = "https://files.pythonhosted.org/packages/60/33/df6d7d04854a58619f8349a51e3edb138324130a7562b0bb21f115bb940f/tornado-6.5.10-cp39-abi3-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:bdf942448169e5336451d0494d7e3d81cfa726d5aa312affdc4682dd62a62f6d", size = 467096, upload-time = "2026-09-15T13:47:38.559Z" },
+ { url = "https://files.pythonhosted.org/packages/29/17/cc35dff68272d685cffd8600ffafbd8067e7d05e7348d9f80caddffbbd5f/tornado-6.5.10-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:69acca6501eed74582b76dbbceee2a91613f54728e3e418346000d7103101676", size = 468067, upload-time = "2026-09-15T13:47:40.085Z" },
+ { url = "https://files.pythonhosted.org/packages/c3/01/6e5349b4e1a53a4b4972a6716785e1fe7407f312063c3972690af8ff301b/tornado-6.5.10-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:66aaa3f57d30c6e6becee83ff28055d5930ac724214bde99393eefda83d5e015", size = 467901, upload-time = "2026-09-15T13:47:41.576Z" },
+ { url = "https://files.pythonhosted.org/packages/28/5e/b4facf94370dba006819c8d304376f8b9fbec6b935b5e51bf45823a9790b/tornado-6.5.10-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:4bd192b959f9128fb99b8898148070ba4574c9589b78bce42d1851131fe85828", size = 467308, upload-time = "2026-09-15T13:47:43.145Z" },
+ { url = "https://files.pythonhosted.org/packages/56/ae/047938e828cafc8eca4c908fafb6588fee944e3af39a0af9d7b602499ae5/tornado-6.5.10-cp39-abi3-win32.whl", hash = "sha256:302eb1e0e3e159314eb591920529fdea80acca92df5510a2cec5bbd4f099ec72", size = 468387, upload-time = "2026-09-15T13:47:44.556Z" },
+ { url = "https://files.pythonhosted.org/packages/d8/d4/5901517f05affd752490f6a654ba31b7474664e8dd80bd045a00c220bd88/tornado-6.5.10-cp39-abi3-win_amd64.whl", hash = "sha256:37ae8f150cecfdbf747fc4e12f5e9a97ecd8cf1d4cdb3f119e2de84b11196918", size = 468828, upload-time = "2026-09-15T13:47:45.961Z" },
+ { url = "https://files.pythonhosted.org/packages/f3/1a/fd497f3a7f7b74bb04f4b94536b5c9f80742b5d50501fd27977652ddec16/tornado-6.5.10-cp39-abi3-win_arm64.whl", hash = "sha256:ce045d3c298fddd30e89a2777f97039d1b641eb9518ac7b26a4721903539c694", size = 467847, upload-time = "2026-09-15T13:47:47.283Z" },
+]
+
+[[package]]
+name = "types-pyyaml"
+version = "6.0.12.20260906"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/90/6e/abec85b9013db5b934b0280a6dd104904d84f7bcbaab2e2f3def87ac7463/types_pyyaml-6.0.12.20260906.tar.gz", hash = "sha256:f59c1cc05010b833d2d72287bbaa72610106b28d42d89a907313117faba85212", size = 18649, upload-time = "2026-09-06T06:35:35.362Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/15/c0/fc0644b7ddcfb969e95845837143cb5173ddd6e06ee4ba5fc493cd9329b7/types_pyyaml-6.0.12.20260906-py3-none-any.whl", hash = "sha256:bca893ff0d51df5c9053137d5d0e6ccd36e939a196356f1d5c16372422f5137b", size = 21282, upload-time = "2026-09-06T06:35:34.372Z" },
+]
+
+[[package]]
+name = "typing-extensions"
+version = "4.16.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/f6/cc/6253133b5bb138fc3306cebfbda2c520f545d36b5be2c7255cc528bb45d6/typing_extensions-4.16.0.tar.gz", hash = "sha256:dc983d19a509c94dba722ee6abd33940f7c05a89e243c47e907eb4db6f1a43e5", size = 113555, upload-time = "2026-07-02T08:40:05.92Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/49/d3/b8441a820a491ddfc024b0b0cf0393375b75ea13866d9c66727e54c2fc80/typing_extensions-4.16.0-py3-none-any.whl", hash = "sha256:481caa481374e813c1b176ada14e97f1f67a4539ce9cfeb3f350d78d6370c2e8", size = 45571, upload-time = "2026-07-02T08:40:04.659Z" },
+]
+
+[[package]]
+name = "tzdata"
+version = "2026.5"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/d9/68/f1b440335057bfce71b6e50a9d09445aa2ecbd08359a337976627b8409e7/tzdata-2026.5.tar.gz", hash = "sha256:8cc73c0a0bfca7dbfa59235d60b2eff82231dee33f53d206db1acd9173cfc0a7", size = 200404, upload-time = "2026-10-03T09:23:14.143Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/94/21/1e5995a1c920cce14e4bffae20c665ec10e7ed03ab25e006cd741092b718/tzdata-2026.5-py2.py3-none-any.whl", hash = "sha256:b683bd1b6659ddcd810ff02ad09ba821d4bf1065072805063eb35c49617905ac", size = 347996, upload-time = "2026-10-03T09:23:12.535Z" },
+]
diff --git a/yamlfmt.yaml b/yamlfmt.yaml
new file mode 100644
index 0000000..01d655a
--- /dev/null
+++ b/yamlfmt.yaml
@@ -0,0 +1,5 @@
+formatter:
+ type: basic
+ max_line_length: 80
+ scan_folded_as_literal: true
+ retain_line_breaks: true