├── .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 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
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 |
--------------------------------------------------------------------------------