├── .github ├── dependabot.yml └── workflows │ ├── ci.yml │ └── deploy-docs.yml ├── .gitignore ├── LICENSE ├── Makefile ├── README.md ├── docs ├── docs │ └── index.md └── mkdocs.yml ├── main.py ├── pyproject.toml ├── requirements.txt ├── setup.cfg ├── setup.py ├── tests ├── __init__.py └── test_smoke.py └── uvicorn_browser ├── __init__.py ├── __main__.py ├── driver.py ├── main.py ├── py.typed └── reload.py /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: "pip" 4 | directory: "/" 5 | schedule: 6 | interval: "monthly" 7 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | push: 5 | branches: [main] 6 | pull_request: 7 | branches: [main] 8 | 9 | jobs: 10 | tests: 11 | name: "Python ${{ matrix.python-version }} ${{ matrix.os }}" 12 | runs-on: "${{ matrix.os }}" 13 | timeout-minutes: 30 14 | strategy: 15 | fail-fast: false 16 | matrix: 17 | python-version: ["3.8", "3.9", "3.10"] 18 | os: [windows-latest, ubuntu-latest, macos-latest] 19 | 20 | steps: 21 | - name: Checkout changes 22 | uses: actions/checkout@v2 23 | 24 | - name: Set up Python ${{ matrix.python-version }} 25 | uses: actions/setup-python@v2 26 | with: 27 | python-version: ${{ matrix.python-version }} 28 | cache: 'pip' 29 | 30 | - name: Install dependencies 31 | run: | 32 | python -m pip install --upgrade pip 33 | python -m pip install -r requirements.txt 34 | 35 | - name: Lint Python 36 | run: make lint 37 | 38 | - name: Run tests 39 | run: make tests 40 | 41 | - name: Upload coverage 42 | uses: codecov/codecov-action@v2 43 | -------------------------------------------------------------------------------- /.github/workflows/deploy-docs.yml: -------------------------------------------------------------------------------- 1 | name: Publish docs via GitHub Pages 2 | on: 3 | push: 4 | branches: 5 | - main 6 | 7 | jobs: 8 | build: 9 | name: Deploy docs 10 | runs-on: ubuntu-latest 11 | steps: 12 | - name: Checkout main 13 | uses: actions/checkout@v2 14 | 15 | - name: Deploy docs 16 | uses: mhausenblas/mkdocs-deploy-gh-pages@master 17 | env: 18 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 19 | CONFIG_FILE: docs/mkdocs.yml 20 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | wheels/ 23 | share/python-wheels/ 24 | *.egg-info/ 25 | .installed.cfg 26 | *.egg 27 | MANIFEST 28 | 29 | # PyInstaller 30 | # Usually these files are written by a python script from a template 31 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 32 | *.manifest 33 | *.spec 34 | 35 | # Installer logs 36 | pip-log.txt 37 | pip-delete-this-directory.txt 38 | 39 | # Unit test / coverage reports 40 | htmlcov/ 41 | .tox/ 42 | .nox/ 43 | .coverage 44 | .coverage.* 45 | .cache 46 | nosetests.xml 47 | coverage.xml 48 | *.cover 49 | *.py,cover 50 | .hypothesis/ 51 | .pytest_cache/ 52 | cover/ 53 | 54 | # Translations 55 | *.mo 56 | *.pot 57 | 58 | # Django stuff: 59 | *.log 60 | local_settings.py 61 | db.sqlite3 62 | db.sqlite3-journal 63 | 64 | # Flask stuff: 65 | instance/ 66 | .webassets-cache 67 | 68 | # Scrapy stuff: 69 | .scrapy 70 | 71 | # Sphinx documentation 72 | docs/_build/ 73 | 74 | # PyBuilder 75 | .pybuilder/ 76 | target/ 77 | 78 | # Jupyter Notebook 79 | .ipynb_checkpoints 80 | 81 | # IPython 82 | profile_default/ 83 | ipython_config.py 84 | 85 | # pyenv 86 | # For a library or package, you might want to ignore these files since the code is 87 | # intended to run in multiple environments; otherwise, check them in: 88 | # .python-version 89 | 90 | # pipenv 91 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 92 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 93 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 94 | # install all needed dependencies. 95 | #Pipfile.lock 96 | 97 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow 98 | __pypackages__/ 99 | 100 | # Celery stuff 101 | celerybeat-schedule 102 | celerybeat.pid 103 | 104 | # SageMath parsed files 105 | *.sage.py 106 | 107 | # Environments 108 | .env 109 | .venv 110 | env/ 111 | venv/ 112 | ENV/ 113 | env.bak/ 114 | venv.bak/ 115 | 116 | # Spyder project settings 117 | .spyderproject 118 | .spyproject 119 | 120 | # Rope project settings 121 | .ropeproject 122 | 123 | # mkdocs documentation 124 | /site 125 | 126 | # mypy 127 | .mypy_cache/ 128 | .dmypy.json 129 | dmypy.json 130 | 131 | # Pyre type checker 132 | .pyre/ 133 | 134 | # pytype static type analyzer 135 | .pytype/ 136 | 137 | # Cython debug symbols 138 | cython_debug/ 139 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 Marcelo Trylesinski 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | .PHONY: help 2 | help: ## Show this help 3 | @egrep -h '\s##\s' $(MAKEFILE_LIST) | sort | awk 'BEGIN {FS = ":.*?## "}; {printf "\033[36m%-20s\033[0m %s\n", $$1, $$2}' 4 | 5 | 6 | .PHONY: lint 7 | lint: ## Linter the code. 8 | @echo "🚨 Linting code" 9 | isort uvicorn_browser tests --check 10 | flake8 uvicorn_browser tests 11 | mypy uvicorn_browser 12 | black uvicorn_browser tests --check --diff 13 | 14 | 15 | .PHONY: format 16 | format: 17 | @echo "🎨 Formatting code" 18 | isort uvicorn_browser tests 19 | autoflake --remove-all-unused-imports --recursive --remove-unused-variables --in-place uvicorn_browser tests --exclude=__init__.py 20 | black uvicorn_browser tests 21 | 22 | 23 | .PHONY: tests 24 | test: ## Test your code. 25 | @echo "🍜 Running pytest" 26 | pytest tests/ --cov=uvicorn_browser --cov-report=term-missing:skip-covered --cov-report=xml --cov-fail-under 100 27 | 28 | 29 | .PHONY: publish 30 | publish: ## Publish release to PyPI 31 | @echo "🔖 Publish to PyPI" 32 | python setup.py bdist_wheel 33 | twine upload dist/* 34 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

2 | uvicorn-browser 3 |

4 |

5 | 6 | Latest Commit 7 | 8 | 9 | 10 |
11 | 12 | Package version 13 | 14 | 15 | 16 |

17 | 18 |

19 | uvicorn-browser 20 |

21 | 22 | This project is inspired by [autoreload](https://github.com/ChillFish8/autoreload/tree/master). 23 | 24 | ## Installation 25 | 26 | ```bash 27 | pip install uvicorn-browser 28 | ``` 29 | 30 | ## Usage 31 | 32 | Run `uvicorn-browser --help` to see all options. They are the [same as `uvicorn`](https://www.uvicorn.org/deployment/#running-from-the-command-line), plus those: 33 | 34 | ```bash 35 | --reload-url TEXT URL to reload. 36 | --driver TEXT Browser driver. Only used if reload-url is 37 | set. Supported: 'chrome', 'firefox.' 38 | ``` 39 | 40 | ## License 41 | 42 | This project is licensed under the terms of the MIT license. 43 | -------------------------------------------------------------------------------- /docs/docs/index.md: -------------------------------------------------------------------------------- 1 | # Welcome to the Documentation 2 | -------------------------------------------------------------------------------- /docs/mkdocs.yml: -------------------------------------------------------------------------------- 1 | site_name: My Docs 2 | theme: 3 | name: material 4 | -------------------------------------------------------------------------------- /main.py: -------------------------------------------------------------------------------- 1 | from fastapi import FastAPI 2 | 3 | app = FastAPI() 4 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = ["setuptools >= 40.6.0", "wheel"] 3 | build-backend = "setuptools.build_meta" 4 | 5 | [tool.black] 6 | target-version = ['py38'] 7 | 8 | [tool.isort] 9 | profile = "black" 10 | 11 | [tool.mypy] 12 | show_error_codes = true 13 | follow_imports = "skip" 14 | follow_imports_for_stubs = false 15 | ignore_missing_imports = true 16 | 17 | [[tool.mypy.overrides]] 18 | module = "tests.*" 19 | allow_untyped_defs = true 20 | 21 | [tool.pytest.ini_options] 22 | addopts = """\ 23 | --strict-config 24 | --strict-markers 25 | """ 26 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | flake8==4.0.1 2 | isort==5.10.1 3 | black==21.9b0 4 | pytest==6.2.5 5 | pytest-cov==3.0.0 6 | mypy==0.910 7 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [metadata] 2 | name = uvicorn-browser 3 | version = 0.2.0 4 | description = Reload your Browser with code changes! :sparkles: 5 | long_description = file: README.md 6 | long_description_content_type = text/markdown 7 | url = https://github.com/Kludex/uvicorn-browser 8 | author = Marcelo Trylesinski 9 | author_email = marcelotryle@email.com 10 | license = MIT 11 | license_file = LICENSE 12 | classifiers = 13 | License :: OSI Approved :: MIT License 14 | Intended Audience :: Developers 15 | Natural Language :: English 16 | Operating System :: OS Independent 17 | Programming Language :: Python :: 3 :: Only 18 | Programming Language :: Python :: 3 19 | Programming Language :: Python :: 3.6 20 | Programming Language :: Python :: 3.7 21 | Programming Language :: Python :: 3.8 22 | Programming Language :: Python :: 3.9 23 | Programming Language :: Python :: 3.10 24 | Programming Language :: Python :: Implementation :: CPython 25 | Programming Language :: Python :: Implementation :: PyPy 26 | project_urls = 27 | Twitter = https://twitter.com/marcelotryle 28 | 29 | [options] 30 | packages = find: 31 | include_package_data = True 32 | install_requires = 33 | uvicorn==0.16.0 34 | selenium 35 | typing-extensions; python_version < '3.8' 36 | python_requires = >=3.6.1 37 | 38 | [options.entry_points] 39 | console_scripts = 40 | uvicorn = uvicorn_browser.main:main 41 | uvicorn-browser = uvicorn_browser.main:main 42 | 43 | [flake8] 44 | statistics = True 45 | max-line-length = 88 46 | ignore = E203,E501,W503 47 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | from setuptools import setup 2 | 3 | setup() 4 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Kludex/uvicorn-browser/c62e5564d90efbb22ac91c3a928e93749c2210e7/tests/__init__.py -------------------------------------------------------------------------------- /tests/test_smoke.py: -------------------------------------------------------------------------------- 1 | import inspect 2 | 3 | import uvicorn_browser 4 | 5 | 6 | def test_smoke(): 7 | assert inspect.ismodule(uvicorn_browser) 8 | -------------------------------------------------------------------------------- /uvicorn_browser/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Kludex/uvicorn-browser/c62e5564d90efbb22ac91c3a928e93749c2210e7/uvicorn_browser/__init__.py -------------------------------------------------------------------------------- /uvicorn_browser/__main__.py: -------------------------------------------------------------------------------- 1 | from uvicorn_browser.main import main 2 | 3 | if __name__ == "__main__": 4 | main() 5 | -------------------------------------------------------------------------------- /uvicorn_browser/driver.py: -------------------------------------------------------------------------------- 1 | import sys 2 | from typing import Union 3 | 4 | from selenium import webdriver 5 | 6 | if sys.version_info < (3, 8): 7 | from typing_extensions import Literal 8 | else: 9 | from typing import Literal 10 | 11 | 12 | class RefreshableDriver: 13 | def __init__(self, url: str, flavour: Literal["chrome", "firefox"]): 14 | self.url = url 15 | self.driver = get_driver(flavour) 16 | 17 | def reload(self): 18 | self.driver.refresh() 19 | 20 | def load(self): 21 | self.driver.get(self.url) 22 | 23 | def close(self): 24 | self.driver.close() 25 | 26 | def quit(self): 27 | self.driver.quit() 28 | 29 | 30 | def get_driver(flavour: str) -> Union[webdriver.Firefox, webdriver.Chrome]: 31 | if flavour == "firefox": 32 | profile = webdriver.FirefoxProfile() 33 | profile.set_preference("browser.cache.disk.enable", False) 34 | profile.set_preference("browser.cache.memory.enable", False) 35 | profile.set_preference("browser.cache.offline.enable", False) 36 | profile.set_preference("network.http.use-cache", False) 37 | return webdriver.Firefox(executable_path="geckodriver", firefox_profile=profile) 38 | elif flavour == "chrome": 39 | return webdriver.Chrome(executable_path="chromedriver") 40 | 41 | raise ValueError(f"Unknown driver flavour: {flavour}") 42 | -------------------------------------------------------------------------------- /uvicorn_browser/main.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import os 3 | import sys 4 | from typing import Any 5 | 6 | import click 7 | from uvicorn.config import Config 8 | from uvicorn.main import LOGGING_CONFIG 9 | from uvicorn.main import main as uvicorn_main 10 | from uvicorn.server import Server 11 | 12 | from uvicorn_browser.reload import BrowserReload 13 | 14 | if sys.version_info < (3, 8): 15 | from typing_extensions import Literal 16 | else: 17 | from typing import Literal 18 | 19 | url_reloader = click.option("--reload-url", default=None, help="URL to reload.") 20 | driver = click.option( 21 | "--driver", 22 | default="chrome", 23 | help=( 24 | "Browser driver. Only used if reload-url is set." 25 | "Supported: 'chrome', 'firefox.'" 26 | ), 27 | ) 28 | 29 | uvicorn_main = driver(url_reloader(uvicorn_main)) 30 | 31 | 32 | def decorator(func): 33 | def wrapper(**kwargs): 34 | log_config = kwargs.get("log_config") 35 | kwargs["log_config"] = LOGGING_CONFIG if log_config is None else log_config 36 | 37 | reload_dirs = kwargs.get("reload_dirs") 38 | kwargs["reload_dirs"] = reload_dirs if reload_dirs else None 39 | 40 | reload_includes = kwargs.get("reload_includes") 41 | kwargs["reload_includes"] = reload_includes if reload_includes else None 42 | 43 | reload_excludes = kwargs.get("reload_excludes") 44 | kwargs["reload_excludes"] = reload_excludes if reload_excludes else None 45 | 46 | reload_url = kwargs.pop("reload_url") 47 | driver = kwargs.pop("driver") 48 | if reload_url: 49 | kwargs["reload"] = True 50 | run(**kwargs, reload_url=reload_url, driver=driver) 51 | else: 52 | func(**kwargs) 53 | 54 | return wrapper 55 | 56 | 57 | uvicorn_main.callback = decorator(uvicorn_main.callback) 58 | main = uvicorn_main 59 | 60 | 61 | def run(reload_url: str, driver: Literal["chrome", "firefox"], **kwargs: Any) -> None: 62 | app_dir = kwargs.pop("app_dir", None) 63 | if app_dir is not None: 64 | sys.path.insert(0, app_dir) 65 | 66 | config = Config(**kwargs) 67 | server = Server(config=config) 68 | 69 | if (config.reload or config.workers > 1) and not isinstance(kwargs.get("app"), str): 70 | logger = logging.getLogger("uvicorn.error") 71 | logger.warning( 72 | "You must pass the application as an import string to enable 'reload' or " 73 | "'workers'." 74 | ) 75 | sys.exit(1) 76 | 77 | sock = config.bind_socket() 78 | BrowserReload(reload_url, driver, config, target=server.run, sockets=[sock]).run() 79 | 80 | if config.uds: 81 | os.remove(config.uds) 82 | -------------------------------------------------------------------------------- /uvicorn_browser/py.typed: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Kludex/uvicorn-browser/c62e5564d90efbb22ac91c3a928e93749c2210e7/uvicorn_browser/py.typed -------------------------------------------------------------------------------- /uvicorn_browser/reload.py: -------------------------------------------------------------------------------- 1 | import sys 2 | from pathlib import Path 3 | from socket import socket 4 | from time import sleep 5 | from typing import Callable, List, Optional 6 | 7 | from uvicorn.config import Config 8 | from uvicorn.supervisors.basereload import BaseReload 9 | from uvicorn.supervisors.watchgodreload import CustomWatcher, logger 10 | 11 | from uvicorn_browser.driver import RefreshableDriver 12 | 13 | if sys.version_info < (3, 8): 14 | from typing_extensions import Literal 15 | else: 16 | from typing import Literal 17 | 18 | 19 | class BrowserReload(BaseReload): 20 | def __init__( 21 | self, 22 | url: str, 23 | driver: Literal["chrome", "firefox"], 24 | config: Config, 25 | target: Callable[[Optional[List[socket]]], None], 26 | sockets: List[socket], 27 | ) -> None: 28 | super().__init__(config, target, sockets) 29 | self.reloader_name = "browser" 30 | self.watchers = [] 31 | reload_dirs = [] 32 | for directory in config.reload_dirs: 33 | if Path.cwd() not in directory.parents: 34 | reload_dirs.append(directory) 35 | if Path.cwd() not in reload_dirs: 36 | reload_dirs.append(Path.cwd()) 37 | for w in reload_dirs: 38 | self.watchers.append(CustomWatcher(w.resolve(), self.config)) 39 | self.driver = RefreshableDriver(url=url, flavour=driver) 40 | 41 | def startup(self) -> None: 42 | super().startup() 43 | sleep(1) 44 | self.driver.load() 45 | 46 | def should_restart(self) -> bool: 47 | for watcher in self.watchers: 48 | change = watcher.check() 49 | if change != set(): 50 | message = "BrowserReload detected file change in '%s'. Reloading..." 51 | logger.warning(message, [c[1] for c in change]) 52 | self.driver.reload() 53 | return True 54 | 55 | return False 56 | 57 | def shutdown(self) -> None: 58 | # TODO: Needs to tier down the browser resources w/ self.driver.close/quit() 59 | super().shutdown() 60 | --------------------------------------------------------------------------------