├── .gitignore ├── .pre-commit-config.yaml ├── CHANGELOG.md ├── LICENSE ├── README.md ├── docs └── images │ ├── make_response_parameter_info.png │ ├── make_response_quick_info.png │ └── request_htmx_code_completion.png ├── pyproject.toml ├── setup.cfg ├── setup.py ├── src └── htmx_flask │ ├── __init__.py │ ├── constants.py │ ├── extension.py │ ├── requests.py │ └── response.py └── tests ├── conftest.py ├── test_request.py └── test_response.py /.gitignore: -------------------------------------------------------------------------------- 1 | 2 | # Created by https://www.toptal.com/developers/gitignore/api/windows,pycharm,powershell,vim,python,flask,linux,macos,venv 3 | # Edit at https://www.toptal.com/developers/gitignore?templates=windows,pycharm,powershell,vim,python,flask,linux,macos,venv 4 | 5 | ### Flask ### 6 | instance/* 7 | !instance/.gitignore 8 | .webassets-cache 9 | .env 10 | 11 | ### Flask.Python Stack ### 12 | # Byte-compiled / optimized / DLL files 13 | __pycache__/ 14 | *.py[cod] 15 | *$py.class 16 | 17 | # C extensions 18 | *.so 19 | 20 | # Distribution / packaging 21 | .Python 22 | build/ 23 | develop-eggs/ 24 | dist/ 25 | downloads/ 26 | eggs/ 27 | .eggs/ 28 | lib/ 29 | lib64/ 30 | parts/ 31 | sdist/ 32 | var/ 33 | wheels/ 34 | share/python-wheels/ 35 | *.egg-info/ 36 | .installed.cfg 37 | *.egg 38 | MANIFEST 39 | 40 | # PyInstaller 41 | # Usually these files are written by a python script from a template 42 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 43 | *.manifest 44 | *.spec 45 | 46 | # Installer logs 47 | pip-log.txt 48 | pip-delete-this-directory.txt 49 | 50 | # Unit test / coverage reports 51 | htmlcov/ 52 | .tox/ 53 | .nox/ 54 | .coverage 55 | .coverage.* 56 | .cache 57 | nosetests.xml 58 | coverage.xml 59 | *.cover 60 | *.py,cover 61 | .hypothesis/ 62 | .pytest_cache/ 63 | cover/ 64 | 65 | # Translations 66 | *.mo 67 | *.pot 68 | 69 | # Django stuff: 70 | *.log 71 | local_settings.py 72 | db.sqlite3 73 | db.sqlite3-journal 74 | 75 | # Flask stuff: 76 | instance/ 77 | 78 | # Scrapy stuff: 79 | .scrapy 80 | 81 | # Sphinx documentation 82 | docs/_build/ 83 | 84 | # PyBuilder 85 | .pybuilder/ 86 | target/ 87 | 88 | # Jupyter Notebook 89 | .ipynb_checkpoints 90 | 91 | # IPython 92 | profile_default/ 93 | ipython_config.py 94 | 95 | # pyenv 96 | # For a library or package, you might want to ignore these files since the code is 97 | # intended to run in multiple environments; otherwise, check them in: 98 | # .python-version 99 | 100 | # pipenv 101 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 102 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 103 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 104 | # install all needed dependencies. 105 | #Pipfile.lock 106 | 107 | # poetry 108 | # Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. 109 | # This is especially recommended for binary packages to ensure reproducibility, and is more 110 | # commonly ignored for libraries. 111 | # https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control 112 | #poetry.lock 113 | 114 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow 115 | __pypackages__/ 116 | 117 | # Celery stuff 118 | celerybeat-schedule 119 | celerybeat.pid 120 | 121 | # SageMath parsed files 122 | *.sage.py 123 | 124 | # Environments 125 | .venv 126 | env/ 127 | venv/ 128 | ENV/ 129 | env.bak/ 130 | venv.bak/ 131 | 132 | # Spyder project settings 133 | .spyderproject 134 | .spyproject 135 | 136 | # Rope project settings 137 | .ropeproject 138 | 139 | # mkdocs documentation 140 | /site 141 | 142 | # mypy 143 | .mypy_cache/ 144 | .dmypy.json 145 | dmypy.json 146 | 147 | # Pyre type checker 148 | .pyre/ 149 | 150 | # pytype static type analyzer 151 | .pytype/ 152 | 153 | # Cython debug symbols 154 | cython_debug/ 155 | 156 | # PyCharm 157 | # JetBrains specific template is maintained in a separate JetBrains.gitignore that can 158 | # be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore 159 | # and can be added to the global gitignore or merged into this file. For a more nuclear 160 | # option (not recommended) you can uncomment the following to ignore the entire idea folder. 161 | #.idea/ 162 | 163 | ### Linux ### 164 | *~ 165 | 166 | # temporary files which can be created if a process still has a handle open of a deleted file 167 | .fuse_hidden* 168 | 169 | # KDE directory preferences 170 | .directory 171 | 172 | # Linux trash folder which might appear on any partition or disk 173 | .Trash-* 174 | 175 | # .nfs files are created when an open file is removed but is still being accessed 176 | .nfs* 177 | 178 | ### macOS ### 179 | # General 180 | .DS_Store 181 | .AppleDouble 182 | .LSOverride 183 | 184 | # Icon must end with two \r 185 | Icon 186 | 187 | 188 | # Thumbnails 189 | ._* 190 | 191 | # Files that might appear in the root of a volume 192 | .DocumentRevisions-V100 193 | .fseventsd 194 | .Spotlight-V100 195 | .TemporaryItems 196 | .Trashes 197 | .VolumeIcon.icns 198 | .com.apple.timemachine.donotpresent 199 | 200 | # Directories potentially created on remote AFP share 201 | .AppleDB 202 | .AppleDesktop 203 | Network Trash Folder 204 | Temporary Items 205 | .apdisk 206 | 207 | ### PowerShell ### 208 | # Exclude packaged modules 209 | *.zip 210 | 211 | # Exclude .NET assemblies from source 212 | *.dll 213 | 214 | ### PyCharm ### 215 | # Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio, WebStorm and Rider 216 | # Reference: https://intellij-support.jetbrains.com/hc/en-us/articles/206544839 217 | 218 | # User-specific stuff 219 | .idea/**/workspace.xml 220 | .idea/**/tasks.xml 221 | .idea/**/usage.statistics.xml 222 | .idea/**/dictionaries 223 | .idea/**/shelf 224 | 225 | # AWS User-specific 226 | .idea/**/aws.xml 227 | 228 | # Generated files 229 | .idea/**/contentModel.xml 230 | 231 | # Sensitive or high-churn files 232 | .idea/**/dataSources/ 233 | .idea/**/dataSources.ids 234 | .idea/**/dataSources.local.xml 235 | .idea/**/sqlDataSources.xml 236 | .idea/**/dynamic.xml 237 | .idea/**/uiDesigner.xml 238 | .idea/**/dbnavigator.xml 239 | 240 | # Gradle 241 | .idea/**/gradle.xml 242 | .idea/**/libraries 243 | 244 | # Gradle and Maven with auto-import 245 | # When using Gradle or Maven with auto-import, you should exclude module files, 246 | # since they will be recreated, and may cause churn. Uncomment if using 247 | # auto-import. 248 | # .idea/artifacts 249 | # .idea/compiler.xml 250 | # .idea/jarRepositories.xml 251 | # .idea/modules.xml 252 | # .idea/*.iml 253 | # .idea/modules 254 | # *.iml 255 | # *.ipr 256 | 257 | # CMake 258 | cmake-build-*/ 259 | 260 | # Mongo Explorer plugin 261 | .idea/**/mongoSettings.xml 262 | 263 | # File-based project format 264 | *.iws 265 | 266 | # IntelliJ 267 | out/ 268 | 269 | # mpeltonen/sbt-idea plugin 270 | .idea_modules/ 271 | 272 | # JIRA plugin 273 | atlassian-ide-plugin.xml 274 | 275 | # Cursive Clojure plugin 276 | .idea/replstate.xml 277 | 278 | # SonarLint plugin 279 | .idea/sonarlint/ 280 | 281 | # Crashlytics plugin (for Android Studio and IntelliJ) 282 | com_crashlytics_export_strings.xml 283 | crashlytics.properties 284 | crashlytics-build.properties 285 | fabric.properties 286 | 287 | # Editor-based Rest Client 288 | .idea/httpRequests 289 | 290 | # Android studio 3.1+ serialized cache file 291 | .idea/caches/build_file_checksums.ser 292 | 293 | ### PyCharm Patch ### 294 | # Comment Reason: https://github.com/joeblau/gitignore.io/issues/186#issuecomment-215987721 295 | 296 | # *.iml 297 | # modules.xml 298 | # .idea/misc.xml 299 | # *.ipr 300 | 301 | # Sonarlint plugin 302 | # https://plugins.jetbrains.com/plugin/7973-sonarlint 303 | .idea/**/sonarlint/ 304 | 305 | # SonarQube Plugin 306 | # https://plugins.jetbrains.com/plugin/7238-sonarqube-community-plugin 307 | .idea/**/sonarIssues.xml 308 | 309 | # Markdown Navigator plugin 310 | # https://plugins.jetbrains.com/plugin/7896-markdown-navigator-enhanced 311 | .idea/**/markdown-navigator.xml 312 | .idea/**/markdown-navigator-enh.xml 313 | .idea/**/markdown-navigator/ 314 | 315 | # Cache file creation bug 316 | # See https://youtrack.jetbrains.com/issue/JBR-2257 317 | .idea/$CACHE_FILE$ 318 | 319 | # CodeStream plugin 320 | # https://plugins.jetbrains.com/plugin/12206-codestream 321 | .idea/codestream.xml 322 | 323 | ### Python ### 324 | # Byte-compiled / optimized / DLL files 325 | 326 | # C extensions 327 | 328 | # Distribution / packaging 329 | 330 | # PyInstaller 331 | # Usually these files are written by a python script from a template 332 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 333 | 334 | # Installer logs 335 | 336 | # Unit test / coverage reports 337 | 338 | # Translations 339 | 340 | # Django stuff: 341 | 342 | # Flask stuff: 343 | 344 | # Scrapy stuff: 345 | 346 | # Sphinx documentation 347 | 348 | # PyBuilder 349 | 350 | # Jupyter Notebook 351 | 352 | # IPython 353 | 354 | # pyenv 355 | # For a library or package, you might want to ignore these files since the code is 356 | # intended to run in multiple environments; otherwise, check them in: 357 | # .python-version 358 | 359 | # pipenv 360 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 361 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 362 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 363 | # install all needed dependencies. 364 | 365 | # poetry 366 | # Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. 367 | # This is especially recommended for binary packages to ensure reproducibility, and is more 368 | # commonly ignored for libraries. 369 | # https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control 370 | 371 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow 372 | 373 | # Celery stuff 374 | 375 | # SageMath parsed files 376 | 377 | # Environments 378 | 379 | # Spyder project settings 380 | 381 | # Rope project settings 382 | 383 | # mkdocs documentation 384 | 385 | # mypy 386 | 387 | # Pyre type checker 388 | 389 | # pytype static type analyzer 390 | 391 | # Cython debug symbols 392 | 393 | # PyCharm 394 | # JetBrains specific template is maintained in a separate JetBrains.gitignore that can 395 | # be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore 396 | # and can be added to the global gitignore or merged into this file. For a more nuclear 397 | # option (not recommended) you can uncomment the following to ignore the entire idea folder. 398 | 399 | ### venv ### 400 | # Virtualenv 401 | # http://iamzed.com/2009/05/07/a-primer-on-virtualenv/ 402 | [Bb]in 403 | [Ii]nclude 404 | [Ll]ib 405 | [Ll]ib64 406 | [Ll]ocal 407 | [Ss]cripts 408 | pyvenv.cfg 409 | pip-selfcheck.json 410 | 411 | ### Vim ### 412 | # Swap 413 | [._]*.s[a-v][a-z] 414 | !*.svg # comment out if you don't need vector files 415 | [._]*.sw[a-p] 416 | [._]s[a-rt-v][a-z] 417 | [._]ss[a-gi-z] 418 | [._]sw[a-p] 419 | 420 | # Session 421 | Session.vim 422 | Sessionx.vim 423 | 424 | # Temporary 425 | .netrwhist 426 | # Auto-generated tag files 427 | tags 428 | # Persistent undo 429 | [._]*.un~ 430 | 431 | ### Windows ### 432 | # Windows thumbnail cache files 433 | Thumbs.db 434 | Thumbs.db:encryptable 435 | ehthumbs.db 436 | ehthumbs_vista.db 437 | 438 | # Dump file 439 | *.stackdump 440 | 441 | # Folder config file 442 | [Dd]esktop.ini 443 | 444 | # Recycle Bin used on file shares 445 | $RECYCLE.BIN/ 446 | 447 | # Windows Installer files 448 | *.cab 449 | *.msi 450 | *.msix 451 | *.msm 452 | *.msp 453 | 454 | # Windows shortcuts 455 | *.lnk 456 | 457 | # End of https://www.toptal.com/developers/gitignore/api/windows,pycharm,powershell,vim,python,flask,linux,macos,venv 458 | 459 | # Pycharm config 460 | /.idea/ 461 | 462 | ### VisualStudioCode ### 463 | .vscode/* 464 | .vscode/settings.json 465 | .vscode/tasks.json 466 | .vscode/launch.json 467 | .vscode/extensions.json 468 | .vscode/*.code-snippets -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | repos: 2 | - repo: https://github.com/asottile/pyupgrade 3 | rev: v3.0.0 4 | hooks: 5 | - id: pyupgrade 6 | args: [--py3-plus] 7 | 8 | - repo: https://github.com/psf/black 9 | rev: 22.8.0 10 | hooks: 11 | - id: black 12 | language_version: python3.10 13 | 14 | - repo: https://github.com/pre-commit/mirrors-isort 15 | rev: v5.10.1 16 | hooks: 17 | - id: isort 18 | 19 | - repo: https://github.com/pycqa/flake8 20 | rev: 5.0.4 21 | hooks: 22 | - id: flake8 -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | ## Version 0.1.0 2 | Released 2022-10-12 3 | * First release of Flask htmx. 4 | * Supports accessing htmx requests headers from global object `request.htmx` and adding 5 | htmx response headers with custom `make_response` function. 6 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright © 2022 Sergi Pons Freixes and the htmx-flask contributors 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy of this 6 | software and associated documentation files (the “Software”), to deal in the Software 7 | without restriction, including without limitation the rights to use, copy, modify, 8 | merge, publish, distribute, sublicense, and/or sell copies of the Software, and to 9 | permit persons to whom the Software is furnished to do so, subject to the following 10 | conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all copies or 13 | substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, 16 | INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR 17 | PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE 18 | LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT 19 | OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR 20 | OTHER DEALINGS IN THE SOFTWARE. 21 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # htmx-Flask 2 | 3 | htmx-Flask is an extension for Flask that adds support for [htmx](https://htmx.org) to 4 | your application. It simplifies using htmx with Flask by enhancing the global `request` 5 | object and providing a new `make_response` function. 6 | 7 | ## Install 8 | 9 | It's just `pip install htmx-flask` and you're all set. It's a pure Python package that 10 | only needs [`flask`](https://flask.palletsprojects.com) (for obvious reasons!). 11 | 12 | ## Usage 13 | 14 | ### Htmx Request 15 | 16 | Before using the enhanced `request`, you need to initialize the extension with: 17 | 18 | ```python 19 | from flask import Flask 20 | from htmx_flask import Htmx 21 | 22 | htmx = Htmx() 23 | 24 | app = Flask(__name__) 25 | htmx.init_app(app) 26 | ``` 27 | 28 | After that, you can use `htmx_flask.request.htmx` to easily access 29 | [htmx request headers](https://htmx.org/reference/#request_headers). For example, 30 | instead of: 31 | 32 | ```python 33 | from flask import request 34 | from my_app import app 35 | 36 | @app.route("/") 37 | def hello_workd(): 38 | if request.headers.get("HX-Request") == "true": 39 | is_boosted = "Yes!" if request.headers.get("HX-Boosted") == "true" else "No!" 40 | current_url = request.headers.get("HX-Current-URL") 41 | return ( 42 | "

Hello World triggered from a htmx request.

" 43 | f"

Boosted: {is_boosted}

" 44 | f"

The current url is {current_url}." 45 | ) 46 | else: 47 | return "

Hello World triggered from a regular request.

" 48 | ``` 49 | 50 | You can do: 51 | 52 | ```python 53 | from htmx_flask import request 54 | from my_app import app 55 | 56 | @app.route("/") 57 | def hello_workd(): 58 | if request.htmx: 59 | is_boosted = "Yes!" if request.htmx.boosted else "No!" 60 | current_url = request.htmx.current_url 61 | return ( 62 | "

Hello World triggered from a htmx request.

" 63 | f"

Boosted: {is_boosted}

" 64 | f"

The current url is {current_url}." 65 | ) 66 | else: 67 | return "

Hello World triggered from a regular request.

" 68 | ``` 69 | 70 | ### Htmx response 71 | 72 | You might be interested on adding 73 | [htmx response headers](https://htmx.org/reference/#response_headers) to your response. 74 | Use `htmx_flask.make_response` for that. For example, instead of: 75 | 76 | ```python 77 | import json 78 | from flask import make_response 79 | from my_app import app 80 | 81 | @app.route("/hola-mundo") 82 | def hola_mundo(): 83 | body = "Hola Mundo!" 84 | response = make_response(body) 85 | response.headers["HX-Push-URL"] = "false" 86 | trigger_string = json.dumps({"event1":"A message", "event2":"Another message"}) 87 | response.headers["HX-Trigger"] = trigger_string 88 | return response 89 | ``` 90 | 91 | You can do: 92 | 93 | ```python 94 | from htmx_flask import make_response 95 | from my_app import app 96 | 97 | @app.route("/hola-mundo") 98 | def hola_mundo(): 99 | body = "Hola Mundo!" 100 | return make_response( 101 | body, 102 | push_url=False, 103 | trigger={"event1": "A message", "event2": "Another message"}, 104 | ) 105 | ``` 106 | 107 | # IntelliSense 108 | 109 | By using htmx-Flask you will also get the benefits of code completion, parameter info 110 | and quick info on your IDE. Check out these screenshots from PyCharm: 111 | 112 | ![request.htmx autocomplete](https://raw.githubusercontent.com/sponsfreixes/htmx-flask/main/docs/images/request_htmx_code_completion.png) 113 | 114 | ![make_response quick info](https://raw.githubusercontent.com/sponsfreixes/htmx-flask/main/docs/images/make_response_quick_info.png) 115 | 116 | ![make_response parameter info](https://raw.githubusercontent.com/sponsfreixes/htmx-flask/main/docs/images/make_response_parameter_info.png) 117 | 118 | ## How to contribute 119 | 120 | This project uses pre-commit hooks to run black, isort, pyupgrade and flake8 on each commit. To have that running 121 | automatically on your environment, install the project with: 122 | 123 | ```shell 124 | pip install -e .[dev] 125 | ``` 126 | 127 | And then run once: 128 | 129 | ```shell 130 | pre-commit install 131 | ``` 132 | 133 | From now one, every time you commit your files on this project, they will be automatically processed by the tools listed 134 | above. 135 | 136 | ## How to run tests 137 | 138 | You can install pytest and other required dependencies with: 139 | 140 | ```shell 141 | pip install -e .[tests] 142 | ``` 143 | 144 | And then run the test suite with: 145 | 146 | ```shell 147 | pytest 148 | ``` 149 | 150 | -------------------------------------------------------------------------------- /docs/images/make_response_parameter_info.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sponsfreixes/htmx-flask/f0f1779ad80ef0eab0dbaaf315cf1fd1d17ca05d/docs/images/make_response_parameter_info.png -------------------------------------------------------------------------------- /docs/images/make_response_quick_info.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sponsfreixes/htmx-flask/f0f1779ad80ef0eab0dbaaf315cf1fd1d17ca05d/docs/images/make_response_quick_info.png -------------------------------------------------------------------------------- /docs/images/request_htmx_code_completion.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sponsfreixes/htmx-flask/f0f1779ad80ef0eab0dbaaf315cf1fd1d17ca05d/docs/images/request_htmx_code_completion.png -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | # SPDX-License-Identifier: MIT 2 | 3 | [build-system] 4 | requires = ["hatchling"] 5 | build-backend = "hatchling.build" 6 | 7 | [project] 8 | dynamic = ["version"] 9 | name = "htmx-flask" 10 | description = "htmx support for Flask" 11 | authors = [{ name = "Sergi Pons Freixes", email = "sergi@cub3.net" }] 12 | requires-python = ">=3.7" 13 | license = { file = "LICENSE" } 14 | readme = "README.md" 15 | keywords = ["htmx", "flask", "html"] 16 | classifiers = [ 17 | "Development Status :: 4 - Beta", 18 | "Environment :: Web Environment", 19 | "Framework :: Flask", 20 | "Intended Audience :: Developers", 21 | "License :: OSI Approved :: MIT License", 22 | "Operating System :: OS Independent", 23 | "Programming Language :: Python", 24 | "Programming Language :: Python :: 3", 25 | "Topic :: Internet :: WWW/HTTP :: Dynamic Content", 26 | "Topic :: Text Processing :: Markup :: HTML", 27 | "Topic :: Software Development :: Libraries :: Python Modules", 28 | ] 29 | dependencies = [ 30 | "flask>=2.1.0" 31 | ] 32 | 33 | [project.optional-dependencies] 34 | dev = [ 35 | 'pre-commit', 36 | ] 37 | tests = [ 38 | 'pytest', 39 | ] 40 | 41 | [project.urls] 42 | "Source Code" = "https://github.com/sponsfreixes/htmx-flask" 43 | "Issue Tracker" = "https://github.com/sponsfreixes/htmx-flask/issues" 44 | "Changes" = "https://github.com/sponsfreixes/htmx-flask/blob/main/CHANGELOG.md" 45 | 46 | [tool.hatch.version] 47 | path = "src/htmx_flask/__init__.py" 48 | 49 | [tool.isort] 50 | multi_line_output = 3 51 | include_trailing_comma = true 52 | force_grid_wrap = 0 53 | use_parentheses = true 54 | line_length = 88 55 | 56 | [tool.black] 57 | line_length = 88 58 | 59 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [flake8] 2 | # Recommend matching the black line length (default 88), rather than using the default 3 | # of 79 4 | max-line-length = 88 5 | extend-ignore = 6 | # See https://github.com/PycQA/pycodestyle/issues/373 7 | E203 -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | from setuptools import setup 2 | 3 | # GitHub is particularly picky about where it finds Python packaging metadata. 4 | # See: https://github.com/github/feedback/discussions/6456 5 | # 6 | # To be removed once GitHub catches up. 7 | 8 | setup( 9 | name="htmx-flask", 10 | install_requires=[ 11 | "flask", 12 | ], 13 | ) 14 | -------------------------------------------------------------------------------- /src/htmx_flask/__init__.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from typing import cast 4 | 5 | from flask import request 6 | 7 | from htmx_flask.extension import Htmx 8 | from htmx_flask.requests import HtmxAwareRequest 9 | from htmx_flask.response import make_response 10 | 11 | # This is needed for IDEs and mypy to recognize the new request.htmx attribute 12 | request = cast(HtmxAwareRequest, request) 13 | 14 | VERSION = "0.1.0" 15 | 16 | __all__ = [ 17 | "Htmx", 18 | "make_response", 19 | "request", 20 | ] 21 | -------------------------------------------------------------------------------- /src/htmx_flask/constants.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | # True and False values for the HX-* request and response headers 4 | HX_TRUE = "true" 5 | HX_FALSE = "false" 6 | 7 | # Values allowed for the HX-Reswap response header 8 | RESWAPS = [ 9 | "innerHTML", 10 | "outerHTML", 11 | "beforebegin" "afterbegin", 12 | "beforeend", 13 | "afterend", 14 | "delete", 15 | "none", 16 | ] 17 | -------------------------------------------------------------------------------- /src/htmx_flask/extension.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from flask import Flask 4 | 5 | from htmx_flask.requests import HtmxAwareRequest 6 | 7 | 8 | class Htmx: 9 | """Extension for using Flask with htmx.""" 10 | 11 | def __init__(self, app: Flask | None = None): 12 | if app is not None: 13 | self.init_app(app) 14 | 15 | def init_app(self, app: Flask): 16 | """ 17 | Initialize a Flask application for use with this extension instance. This 18 | must be called before accessing ``request.htmx``. 19 | 20 | :param app: The Flask application to initialize. 21 | """ 22 | app.request_class = HtmxAwareRequest 23 | app.extensions["htmx"] = self 24 | -------------------------------------------------------------------------------- /src/htmx_flask/requests.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import typing 4 | from functools import cached_property 5 | 6 | from flask import Request 7 | 8 | from htmx_flask.constants import HX_FALSE, HX_TRUE 9 | 10 | if typing.TYPE_CHECKING: 11 | from werkzeug.datastructures import Headers 12 | 13 | 14 | class HtmxHeaders: 15 | """ 16 | Exposes htmx headers as request instance attributes. The instance evaluates as 17 | ``True`` if it's a htmx request (HX-Request header = 'true'), ``False`` otherwise. 18 | So you can do ``if request.htmx:`` to test if it's a htmx request. 19 | 20 | See https://htmx.org/reference/#request_headers for details about the different 21 | headers. 22 | """ 23 | 24 | def __init__(self, headers: Headers): 25 | self._headers = headers 26 | 27 | def _get_boolean_header(self, header: str) -> bool: 28 | return self._headers.get(header, HX_FALSE).lower() == HX_TRUE 29 | 30 | def __bool__(self) -> bool: 31 | return self._get_boolean_header("HX-Request") 32 | 33 | @cached_property 34 | def boosted(self) -> bool: 35 | """HX-Boosted: Indicates that the request is via an element using hx-boost.""" 36 | return self._get_boolean_header("HX-Boosted") 37 | 38 | @cached_property 39 | def current_url(self) -> str | None: 40 | """HX-Current-URL: The current URL of the browser""" 41 | return self._headers.get("HX-Current-URL") 42 | 43 | @cached_property 44 | def history_restore_request(self) -> bool: 45 | """ 46 | HX-History-Restore-Request: Indicates if the request is for history restoration 47 | after a miss in the local history cache. 48 | """ 49 | return self._get_boolean_header("HX-History-Restore-Request") 50 | 51 | @cached_property 52 | def prompt(self) -> str | None: 53 | """HX-Prompt: The user response to an hx-prompt.""" 54 | return self._headers.get("HX-Prompt") 55 | 56 | @cached_property 57 | def target(self) -> str | None: 58 | """HX-Target: The ``id`` of the target element if it exists.""" 59 | return self._headers.get("HX-Target") 60 | 61 | @cached_property 62 | def trigger(self) -> str | None: 63 | """HX-Trigger: The ``id`` of the triggered element if it exists.""" 64 | return self._headers.get("HX-Trigger") 65 | 66 | @cached_property 67 | def trigger_name(self) -> str | None: 68 | """HX-Trigger-Name: The ``name`` of the triggered element if it exists""" 69 | return self._headers.get("HX-Trigger-Name") 70 | 71 | 72 | class HtmxAwareRequest(Request): 73 | """ 74 | The ``request`` object used in Flask, enhanced with a ``htmx`` attribute 75 | (``request.htmx``). 76 | """ 77 | 78 | def __init__(self, *args, **kwargs): 79 | super().__init__(*args, **kwargs) 80 | self.htmx = HtmxHeaders(self.headers) 81 | -------------------------------------------------------------------------------- /src/htmx_flask/response.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import json 4 | import typing 5 | 6 | from flask import Response 7 | from flask import make_response as flask_make_response 8 | 9 | from htmx_flask.constants import HX_FALSE, HX_TRUE, RESWAPS 10 | 11 | 12 | def _stringify(val): 13 | return val if isinstance(val, str) else json.dumps(val) 14 | 15 | 16 | def make_response( 17 | *args: typing.Any, 18 | location: str | dict | None = None, 19 | push_url: str | False | None = None, 20 | redirect: str | None = None, 21 | refresh: bool = False, 22 | replace_url: str | False | None = None, 23 | reswap: str | None = None, 24 | retarget: str | None = None, 25 | trigger: str | dict | None = None, 26 | trigger_after_settle: str | dict | None = None, 27 | trigger_after_swap: str | dict | None = None, 28 | ) -> Response: 29 | """ 30 | This function can be used as a replacement from ``flask.make_response`` to easily 31 | add htmx response headers to the request. 32 | 33 | See https://htmx.org/reference/#response_headers for more details about the headers. 34 | 35 | :param args: Arguments you would normally use with flask.make_response. 36 | :param location: Allows you to do a client-side redirect that does not do a full 37 | page reload. Accepts a string or a dict (HX-Location). 38 | :param push_url: Pushes a new url into the history stack. Accepts a string—the URL 39 | to be pushed into the location bar—or False—prevents the browser’s history from 40 | being updated—(HX-Push-URL). 41 | :param redirect: Can be used to do a client-side redirect to a new location 42 | (HX-Redirect). 43 | :param refresh: If set to True the client side will do a full refresh of the page 44 | (HX-Refresh). 45 | :param replace_url: Replaces the current URL in the location bar. Accepts a 46 | string—the URL to replace the current URL in the location bar—or False—prevents 47 | the browser’s current URL from being updated—(HX-Replace-URL). 48 | :param reswap: Allows you to specify how the response will be swapped. Possible 49 | values: "innerHTML", "outerHTML", "beforebegin" "afterbegin", "beforeend", 50 | "afterend", "delete", "none". Notice None means to not send the header, which 51 | is different than "none" (HX-Reswap). 52 | :param retarget: A CSS selector that updates the target of the content update to a 53 | different element on the page (HX-Retarget). 54 | :param trigger: Allows you to trigger client side events. Accepts a string or a dict 55 | (HX-Trigger). 56 | :param trigger_after_settle: Allows you to trigger client side events. Accepts a 57 | string or a dict (HT-Trigger-After-Settle). 58 | :param trigger_after_swap: Allows you to trigger client side events. Accepts a 59 | string or a dict (HT-Trigger-After-Swap). 60 | :return: A Flask Response with htmx headers. 61 | """ 62 | if reswap and reswap not in RESWAPS: 63 | raise ValueError( 64 | f"Invalid reswap value. Must be one of {RESWAPS} (or None to ignore)." 65 | ) 66 | resp = flask_make_response(*args) 67 | if location: 68 | resp.headers["HX-Location"] = _stringify(location) 69 | if push_url: 70 | resp.headers["HX-Push-Url"] = push_url 71 | elif push_url is False: 72 | resp.headers["HX-Push-Url"] = HX_FALSE 73 | if redirect: 74 | resp.headers["HX-Redirect"] = redirect 75 | if refresh: 76 | resp.headers["HX-Refresh"] = HX_TRUE 77 | if replace_url: 78 | resp.headers["HX-Replace-Url"] = replace_url 79 | elif replace_url is False: 80 | resp.headers["HX-Replace-Url"] = HX_FALSE 81 | if reswap: 82 | resp.headers["HX-Reswap"] = reswap 83 | if retarget: 84 | resp.headers["HX-Retarget"] = retarget 85 | if trigger: 86 | resp.headers["HX-Trigger"] = _stringify(trigger) 87 | if trigger_after_settle: 88 | resp.headers["HX-Trigger-After-Settle"] = _stringify(trigger_after_settle) 89 | if trigger_after_swap: 90 | resp.headers["HX-Trigger-After-Swap"] = _stringify(trigger_after_swap) 91 | return resp 92 | -------------------------------------------------------------------------------- /tests/conftest.py: -------------------------------------------------------------------------------- 1 | import flask 2 | import pytest 3 | from flask import make_response as flask_make_response 4 | 5 | from htmx_flask import Htmx, make_response, request 6 | 7 | 8 | @pytest.fixture(scope="session") 9 | def flask_app(): 10 | app = flask.Flask(__name__) 11 | app.config.update( 12 | { 13 | "TESTING": True, 14 | } 15 | ) 16 | htmx = Htmx() 17 | htmx.init_app(app) 18 | 19 | @app.get("/test_request") 20 | def test_request(): 21 | if request.htmx: 22 | details = { 23 | "boosted": request.htmx.boosted, 24 | "current_url": request.htmx.current_url, 25 | "history_restore_request": request.htmx.history_restore_request, 26 | "prompt": request.htmx.prompt, 27 | "target": request.htmx.target, 28 | "trigger": request.htmx.trigger, 29 | "trigger_name": request.htmx.trigger, 30 | } 31 | else: 32 | details = {} 33 | return flask_make_response(details) 34 | 35 | @app.get("/test_response") 36 | def test_response(): 37 | print(request.args.get("HX-Retarget")) 38 | return ( 39 | make_response( 40 | {}, 41 | location=request.args.get("HX-Location"), 42 | push_url=request.args.get("HX-Push-Url"), 43 | redirect=request.args.get("HX-Redirect"), 44 | refresh=request.args.get("HX-Refresh") or False, 45 | replace_url=request.args.get("HX-Replace-Url"), 46 | reswap=request.args.get("HX-Reswap"), 47 | retarget=request.args.get("HX-Retarget"), 48 | trigger=request.args.get("HX-Trigger"), 49 | trigger_after_settle=request.args.get("HX-Trigger-After-Settle"), 50 | trigger_after_swap=request.args.get("HX-Trigger-After-Swap"), 51 | ) 52 | if request.args 53 | else make_response({}) 54 | ) 55 | 56 | yield app 57 | 58 | 59 | @pytest.fixture(scope="session") 60 | def flask_client(flask_app): 61 | return flask_app.test_client() 62 | -------------------------------------------------------------------------------- /tests/test_request.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | 4 | class TestRequest: 5 | @pytest.mark.parametrize( 6 | "headers, expected", 7 | [ 8 | ({}, {}), 9 | ( 10 | {"HX-Request": True}, 11 | { 12 | "boosted": False, 13 | "current_url": None, 14 | "history_restore_request": False, 15 | "prompt": None, 16 | "target": None, 17 | "trigger": None, 18 | "trigger_name": None, 19 | }, 20 | ), 21 | ( 22 | {"HX-Request": "true", "HX-Boosted": "true"}, 23 | { 24 | "boosted": True, 25 | "current_url": None, 26 | "history_restore_request": False, 27 | "prompt": None, 28 | "target": None, 29 | "trigger": None, 30 | "trigger_name": None, 31 | }, 32 | ), 33 | ( 34 | { 35 | "HX-Request": "true", 36 | "HX-Current-URL": "http://google.com", 37 | }, 38 | { 39 | "boosted": False, 40 | "current_url": "http://google.com", 41 | "history_restore_request": False, 42 | "prompt": None, 43 | "target": None, 44 | "trigger": None, 45 | "trigger_name": None, 46 | }, 47 | ), 48 | ], 49 | ) 50 | def test_htmx_request(self, flask_client, headers, expected): 51 | response = flask_client.get("/test_request", headers=headers) 52 | 53 | assert response.json == expected 54 | -------------------------------------------------------------------------------- /tests/test_response.py: -------------------------------------------------------------------------------- 1 | import urllib 2 | 3 | import pytest 4 | 5 | from htmx_flask import make_response 6 | 7 | 8 | class TestResponse: 9 | @pytest.mark.parametrize( 10 | "query_args", 11 | [ 12 | { 13 | "HX-Location": None, 14 | "HX-Push-Url": None, 15 | "HX-Redirect": None, 16 | "HX-Refresh": None, 17 | "HX-Replace-Url": None, 18 | "HX-Reswap": None, 19 | "HX-Retarget": None, 20 | "HX-Trigger": None, 21 | "HX-Trigger-After-Settle": None, 22 | "HX-Trigger-After-Swap": None, 23 | }, 24 | { 25 | "HX-Location": "/foo", 26 | "HX-Push-Url": "/bar", 27 | "HX-Redirect": "/baz", 28 | "HX-Refresh": None, 29 | "HX-Replace-Url": "/hello-world", 30 | "HX-Reswap": "outerHTML", 31 | "HX-Retarget": "#idname", 32 | "HX-Trigger": "event", 33 | "HX-Trigger-After-Settle": "event2", 34 | "HX-Trigger-After-Swap": "event3", 35 | }, 36 | ], 37 | ) 38 | def test_htmx_response(self, flask_client, query_args): 39 | args = "&".join( 40 | f"{k}={urllib.parse.quote(v) if isinstance(v, str) else v}" 41 | for k, v in query_args.items() 42 | if v is not None 43 | ) 44 | if args: 45 | url = f"/test_response?{args}" 46 | else: 47 | url = "/test_response" 48 | 49 | response = flask_client.get(url) 50 | 51 | assert response.json == {} 52 | for header, value in query_args.items(): 53 | if value is None: 54 | assert header not in response.headers 55 | else: 56 | received = response.headers[header] 57 | assert str(received) == str(value) 58 | 59 | @pytest.mark.parametrize( 60 | "body, data", 61 | [ 62 | ( 63 | {}, 64 | { 65 | "HX-Location": (None, None), 66 | "HX-Push-Url": (None, None), 67 | "HX-Redirect": (None, None), 68 | "HX-Refresh": (None, None), 69 | "HX-Replace-Url": (None, None), 70 | "HX-Reswap": (None, None), 71 | "HX-Retarget": (None, None), 72 | "HX-Trigger": (None, None), 73 | "HX-Trigger-After-Settle": (None, None), 74 | "HX-Trigger-After-Swap": (None, None), 75 | }, 76 | ), 77 | ( 78 | {"foo": "bar"}, 79 | { 80 | "HX-Location": ("/test", "/test"), 81 | "HX-Push-Url": ("http://google.com", "http://google.com"), 82 | "HX-Redirect": ("/baz", "/baz"), 83 | "HX-Refresh": (None, None), 84 | "HX-Replace-Url": ("http://yahoo.com", "http://yahoo.com"), 85 | "HX-Reswap": ("outerHTML", "outerHTML"), 86 | "HX-Retarget": ("#idname", "#idname"), 87 | "HX-Trigger": ("event", "event"), 88 | "HX-Trigger-After-Settle": ("event2", "event2"), 89 | "HX-Trigger-After-Swap": ("event3", "event3"), 90 | }, 91 | ), 92 | ( 93 | "foo", 94 | { 95 | "HX-Location": ( 96 | {"path": "/test2", "target": "#testdiv"}, 97 | '{"path": "/test2", "target": "#testdiv"}', 98 | ), 99 | "HX-Push-Url": (False, "false"), 100 | "HX-Redirect": (None, None), 101 | "HX-Refresh": (True, "true"), 102 | "HX-Replace-Url": (False, "false"), 103 | "HX-Reswap": ("none", "none"), 104 | "HX-Retarget": (None, None), 105 | "HX-Trigger": ( 106 | {"showMessage": "Here Is A Message"}, 107 | '{"showMessage": "Here Is A Message"}', 108 | ), 109 | "HX-Trigger-After-Settle": ( 110 | { 111 | "showMessage": { 112 | "level": "info", 113 | "message": "Here Is A Message", 114 | } 115 | }, 116 | '{"showMessage": {"level": "info", "message": "Here Is A Message"}}', # noqa 117 | ), 118 | "HX-Trigger-After-Swap": ( 119 | {"event1": "A message", "event2": "Another message"}, 120 | '{"event1": "A message", "event2": "Another message"}', 121 | ), 122 | }, 123 | ), 124 | ], 125 | ) 126 | def test_make_response(self, flask_app, body, data): 127 | mapping = { 128 | "location": "HX-Location", 129 | "push_url": "HX-Push-Url", 130 | "redirect": "HX-Redirect", 131 | "refresh": "HX-Refresh", 132 | "replace_url": "HX-Replace-Url", 133 | "reswap": "HX-Reswap", 134 | "retarget": "HX-Retarget", 135 | "trigger": "HX-Trigger", 136 | "trigger_after_settle": "HX-Trigger-After-Settle", 137 | "trigger_after_swap": "HX-Trigger-After-Swap", 138 | } 139 | kwargs = {key: data[header][0] for key, header in mapping.items()} 140 | kwargs = {k: v for k, v in kwargs.items() if v is not None} 141 | with flask_app.app_context(): 142 | resp = make_response(body, **kwargs) 143 | for header, value in data.items(): 144 | if value[0] is None: 145 | assert header not in resp.headers 146 | else: 147 | assert value[1] == resp.headers[header] 148 | --------------------------------------------------------------------------------