├── requirements.txt ├── src └── hooks │ ├── asyncio │ ├── __init__.py │ ├── use.py │ └── reducers.py │ ├── backends │ ├── __init__.py │ ├── backend_state.py │ ├── interface.py │ ├── async_interface.py │ ├── memory_backend.py │ ├── threadsafe_backend.py │ └── python_objects_backend.py │ ├── plugins │ ├── __init__.py │ ├── discovery.py │ ├── redux.py │ ├── zustand.py │ ├── redux_async.py │ └── redis_backend.py │ ├── __init__.py │ ├── utils.py │ ├── scope.py │ ├── use.py │ ├── reducers.py │ └── frame_utils.py ├── examples └── hooks-in-apis │ ├── api │ ├── __init__.py │ ├── simple_points_app.py │ └── bears_app.py │ └── setup.py ├── tests ├── pytest.ini ├── test_plugins │ ├── test_zustand.py │ └── test_redux.py ├── test_hooks │ ├── test_use_context.py │ ├── test_performance.py │ ├── test_use_effect.py │ ├── test_custom_hooks.py │ ├── test_use_state.py │ ├── test_use_reducer.py │ └── test_hook_scope.py └── conftest.py ├── docs ├── images │ ├── favicon.ico │ ├── flask_example.png │ ├── simple_code_example.png │ └── state_auto_scope_example.png ├── async.md ├── plugins │ ├── zustand.md │ └── redux.md ├── getting_started.md ├── backends │ └── default.md ├── hooks │ ├── additional_hooks │ │ ├── custom_hooks.md │ │ └── use_reducer.md │ └── base_hooks │ │ ├── use_context.md │ │ ├── use_effect.md │ │ └── use_state.md ├── inspiration.md ├── index.md └── scoping │ ├── default_scoping.md │ └── scope_decorator.md ├── setup.cfg ├── .github ├── ISSUE_TEMPLATE │ ├── config.yml │ ├── question.md │ ├── feature_request.md │ └── bug_report.md ├── workflows │ ├── release-drafter.yml │ ├── greetings.yml │ ├── ci.yml │ └── build.yml ├── .stale.yml ├── release-drafter.yml ├── dependabot.yml └── PULL_REQUEST_TEMPLATE.md ├── .idea ├── vcs.xml ├── .gitignore ├── inspectionProfiles │ ├── profiles_settings.xml │ └── Project_Default.xml ├── modules.xml ├── misc.xml └── pyhooks.iml ├── cookiecutter-config-file.yml ├── .editorconfig ├── .pre-commit-config.yaml ├── assets └── images │ └── coverage.svg ├── LICENSE ├── SECURITY.md ├── CONTRIBUTING.md ├── mkdocs.yml ├── CODE_OF_CONDUCT.md ├── Makefile ├── pyproject.toml ├── README.md └── .gitignore /requirements.txt: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/hooks/asyncio/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/hooks/backends/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /examples/hooks-in-apis/api/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/hooks/plugins/__init__.py: -------------------------------------------------------------------------------- 1 | from .discovery import * 2 | -------------------------------------------------------------------------------- /tests/pytest.ini: -------------------------------------------------------------------------------- 1 | # pytest.ini 2 | [pytest] 3 | asyncio_mode=auto 4 | -------------------------------------------------------------------------------- /docs/images/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/amitassaraf/python-hooks/HEAD/docs/images/favicon.ico -------------------------------------------------------------------------------- /docs/images/flask_example.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/amitassaraf/python-hooks/HEAD/docs/images/flask_example.png -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [darglint] 2 | # https://github.com/terrencepreilly/darglint 3 | strictness = long 4 | docstring_style = google 5 | -------------------------------------------------------------------------------- /docs/images/simple_code_example.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/amitassaraf/python-hooks/HEAD/docs/images/simple_code_example.png -------------------------------------------------------------------------------- /docs/images/state_auto_scope_example.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/amitassaraf/python-hooks/HEAD/docs/images/state_auto_scope_example.png -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/config.yml: -------------------------------------------------------------------------------- 1 | # Configuration: https://help.github.com/en/github/building-a-strong-community/configuring-issue-templates-for-your-repository 2 | 3 | blank_issues_enabled: false 4 | -------------------------------------------------------------------------------- /.idea/vcs.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | -------------------------------------------------------------------------------- /.idea/.gitignore: -------------------------------------------------------------------------------- 1 | # Default ignored files 2 | /shelf/ 3 | /workspace.xml 4 | # Editor-based HTTP Client requests 5 | /httpRequests/ 6 | # Datasource local storage ignored files 7 | /dataSources/ 8 | /dataSources.local.xml 9 | -------------------------------------------------------------------------------- /.idea/inspectionProfiles/profiles_settings.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 7 | 8 | -------------------------------------------------------------------------------- /.idea/modules.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /.idea/misc.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 7 | 8 | -------------------------------------------------------------------------------- /cookiecutter-config-file.yml: -------------------------------------------------------------------------------- 1 | # This file contains values from Cookiecutter 2 | 3 | default_context: 4 | project_name: "python-hooks" 5 | project_description: "A React inspired way to code in Python" 6 | organization: "Amit Assaraf" 7 | license: "MIT" 8 | minimal_python_version: 3.9 9 | github_name: "amitassaraf" 10 | email: "amit.assaraf@gmail.com" 11 | version: "0.1.0" 12 | line_length: "88" 13 | create_example_template: "cli" 14 | -------------------------------------------------------------------------------- /.github/workflows/release-drafter.yml: -------------------------------------------------------------------------------- 1 | name: Release Drafter 2 | 3 | on: 4 | push: 5 | # branches to consider in the event; optional, defaults to all 6 | branches: 7 | - master 8 | 9 | jobs: 10 | update_release_draft: 11 | runs-on: ubuntu-latest 12 | steps: 13 | # Drafts your next Release notes as Pull Requests are merged into "master" 14 | - uses: release-drafter/release-drafter@v5.15.0 15 | env: 16 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 17 | -------------------------------------------------------------------------------- /src/hooks/__init__.py: -------------------------------------------------------------------------------- 1 | # type: ignore[attr-defined] 2 | """A React inspired way to code in Python""" 3 | 4 | from importlib import metadata as importlib_metadata 5 | 6 | 7 | def get_version() -> str: 8 | try: 9 | return importlib_metadata.version(__name__) 10 | except importlib_metadata.PackageNotFoundError: # pragma: no cover 11 | return "unknown" 12 | 13 | 14 | version: str = get_version() 15 | 16 | from .reducers import * 17 | from .scope import * 18 | from .use import * 19 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # Check http://editorconfig.org for more information 2 | # This is the main config file for this project: 3 | root = true 4 | 5 | [*] 6 | charset = utf-8 7 | end_of_line = lf 8 | insert_final_newline = true 9 | indent_style = space 10 | indent_size = 2 11 | trim_trailing_whitespace = true 12 | 13 | [*.{py, pyi}] 14 | indent_style = space 15 | indent_size = 4 16 | 17 | [Makefile] 18 | indent_style = tab 19 | 20 | [*.md] 21 | trim_trailing_whitespace = false 22 | 23 | [*.{diff,patch}] 24 | trim_trailing_whitespace = false 25 | -------------------------------------------------------------------------------- /src/hooks/plugins/discovery.py: -------------------------------------------------------------------------------- 1 | # mypy: ignore-errors 2 | 3 | import importlib 4 | import pkgutil 5 | 6 | discovered_plugins = { 7 | name: importlib.import_module(name) 8 | for finder, name, ispkg in pkgutil.iter_modules() 9 | if name.startswith("hooks_") 10 | } 11 | 12 | for module in discovered_plugins.values(): 13 | globals().update( 14 | {n: getattr(module, n) for n in module.__all__} 15 | if hasattr(module, "__all__") 16 | else {k: v for (k, v) in module.__dict__.items() if not k.startswith("_")} 17 | ) 18 | -------------------------------------------------------------------------------- /examples/hooks-in-apis/setup.py: -------------------------------------------------------------------------------- 1 | from distutils.core import setup 2 | 3 | setup( 4 | name="api", 5 | version="1.0", 6 | description="Python Distribution Utilities", 7 | author="Amit Assaraf", 8 | author_email="amit.assaraf@gmail.com", 9 | url="https://www.python.org/sigs/distutils-sig/", 10 | packages=[ 11 | "distutils", 12 | "distutils.command", 13 | "flask", 14 | "requests", 15 | "json", 16 | "time", 17 | "redis", 18 | "typing", 19 | "flask_cors", 20 | ], 21 | ) 22 | -------------------------------------------------------------------------------- /tests/test_plugins/test_zustand.py: -------------------------------------------------------------------------------- 1 | from hooks.plugins.zustand import create 2 | 3 | use_bear_store = create( 4 | { 5 | "bear": "🐻", 6 | }, 7 | lambda set, get: ( 8 | { 9 | "increase_bears": lambda: set(lambda state: {**state, "bear": "🐻🐻"}), 10 | } 11 | ), 12 | ) 13 | 14 | 15 | def test_basic_get_and_set() -> None: 16 | assert use_bear_store(lambda state: state.bear) == "🐻" 17 | 18 | increase_bears = use_bear_store(lambda state: state.increase_bears) 19 | increase_bears() 20 | 21 | assert use_bear_store(lambda state: state.bear) == "🐻🐻" 22 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/question.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: ❓ Question 3 | about: Ask a question about this project 🎓 4 | title: '' 5 | labels: question 6 | assignees: 7 | --- 8 | 9 | ## Checklist 10 | 11 | 12 | 13 | - [ ] I've searched the project's [`issues`](https://github.com/amitassaraf/python-hooks/issues?q=is%3Aissue). 14 | 15 | ## ❓ Question 16 | 17 | 18 | 19 | How can I [...]? 20 | 21 | Is it possible to [...]? 22 | 23 | ## 📎 Additional context 24 | 25 | 26 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: 🚀 Feature request 3 | about: Suggest an idea for this project 🏖 4 | title: '' 5 | labels: enhancement 6 | assignees: 7 | --- 8 | 9 | ## 🚀 Feature Request 10 | 11 | 12 | 13 | ## 🔈 Motivation 14 | 15 | 16 | 17 | ## 🛰 Alternatives 18 | 19 | 20 | 21 | ## 📎 Additional context 22 | 23 | 24 | -------------------------------------------------------------------------------- /src/hooks/backends/backend_state.py: -------------------------------------------------------------------------------- 1 | from typing import Any, Optional 2 | 3 | from .async_interface import AsyncHooksBackend 4 | from .memory_backend import MemoryBackend as DefaultHooksBackend 5 | 6 | BACKEND_KEY = "__hooks_backend__" 7 | 8 | 9 | def set_hooks_backend(backend: type) -> None: 10 | globals()[BACKEND_KEY] = backend 11 | 12 | 13 | def get_hooks_backend(using_async: Optional[bool] = False) -> Any: 14 | backend = globals().get(BACKEND_KEY, DefaultHooksBackend) 15 | if using_async and not issubclass(backend, AsyncHooksBackend): 16 | raise Exception("You cannot use async hooks with a non-async backend") 17 | return backend 18 | -------------------------------------------------------------------------------- /.github/workflows/greetings.yml: -------------------------------------------------------------------------------- 1 | name: Greetings 2 | 3 | on: [pull_request, issues] 4 | 5 | jobs: 6 | greeting: 7 | runs-on: ubuntu-latest 8 | steps: 9 | - uses: actions/first-interaction@v1 10 | with: 11 | repo-token: ${{ secrets.GITHUB_TOKEN }} 12 | pr-message: 'Hello @${{ github.actor }}, thank you for submitting a PR! We will respond as soon as possible.' 13 | issue-message: | 14 | Hello @${{ github.actor }}, thank you for your interest in our work! 15 | 16 | If this is a bug report, please provide screenshots and **minimum viable code to reproduce your issue**, otherwise we can not help you. 17 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: ci 2 | on: 3 | push: 4 | branches: 5 | - master 6 | - main 7 | permissions: 8 | contents: write 9 | jobs: 10 | deploy: 11 | runs-on: ubuntu-latest 12 | steps: 13 | - uses: actions/checkout@v3 14 | - uses: actions/setup-python@v4 15 | with: 16 | python-version: 3.x 17 | - run: echo "cache_id=$(date --utc '+%V')" >> $GITHUB_ENV 18 | - uses: actions/cache@v3 19 | with: 20 | key: mkdocs-material-${{ env.cache_id }} 21 | path: .cache 22 | restore-keys: | 23 | mkdocs-material- 24 | - run: pip install mkdocs-material 25 | - run: mkdocs gh-deploy --force 26 | -------------------------------------------------------------------------------- /.github/.stale.yml: -------------------------------------------------------------------------------- 1 | # Number of days of inactivity before an issue becomes stale 2 | daysUntilStale: 60 3 | # Number of days of inactivity before a stale issue is closed 4 | daysUntilClose: 7 5 | # Issues with these labels will never be considered stale 6 | exemptLabels: 7 | - pinned 8 | - security 9 | # Label to use when marking an issue as stale 10 | staleLabel: wontfix 11 | # Comment to post when marking an issue as stale. Set to `false` to disable 12 | markComment: > 13 | This issue has been automatically marked as stale because it has not had 14 | recent activity. It will be closed if no further activity occurs. Thank you 15 | for your contributions. 16 | # Comment to post when closing a stale issue. Set to `false` to disable 17 | closeComment: false 18 | -------------------------------------------------------------------------------- /examples/hooks-in-apis/api/simple_points_app.py: -------------------------------------------------------------------------------- 1 | from flask import Flask 2 | 3 | from hooks import hook_scope, use_state 4 | from hooks.plugins.redis_backend import RedisBackend 5 | 6 | app = Flask(__name__) 7 | 8 | RedisBackend.use("localhost", 6379) 9 | 10 | 11 | @app.route("/") 12 | def get_app_points(): 13 | app_points, set_app_points = use_state(0) 14 | set_app_points(app_points + 1) 15 | return f"App so far got {app_points} points" 16 | 17 | 18 | @app.route("/user/") 19 | @hook_scope(parametrize=["username"]) 20 | def get_user_points(username: str): 21 | user_points, set_user_points = use_state(0) 22 | set_user_points(user_points + 1) 23 | return f"{username} so far got {user_points} points" 24 | 25 | 26 | if __name__ == "__main__": 27 | app.run(debug=True, host="0.0.0.0", port=9999) 28 | -------------------------------------------------------------------------------- /.idea/pyhooks.iml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 15 | 16 | 18 | 19 | -------------------------------------------------------------------------------- /src/hooks/backends/interface.py: -------------------------------------------------------------------------------- 1 | from types import SimpleNamespace 2 | from typing import Any, TypeVar, Union 3 | 4 | T = TypeVar("T") 5 | 6 | 7 | class HooksBackend(SimpleNamespace): 8 | @classmethod 9 | def use(cls, *args: Any, **kwargs: Any) -> Any: 10 | from .backend_state import set_hooks_backend 11 | 12 | set_hooks_backend(cls) 13 | return cls 14 | 15 | @classmethod 16 | def load(cls, identifier: str) -> Any: 17 | raise NotImplemented 18 | 19 | @classmethod 20 | def save(cls, identifier: str, value: Any) -> Union[bool, None, Any]: 21 | raise NotImplemented 22 | 23 | @classmethod 24 | def exists(cls, identifier: str) -> bool: 25 | raise NotImplemented 26 | 27 | @classmethod 28 | def reset_backend(cls) -> None: 29 | raise NotImplemented 30 | -------------------------------------------------------------------------------- /examples/hooks-in-apis/api/bears_app.py: -------------------------------------------------------------------------------- 1 | from flask import Flask 2 | 3 | from hooks.plugins.redis_backend import RedisBackend 4 | from hooks.plugins.zustand import create 5 | 6 | app = Flask(__name__) 7 | 8 | RedisBackend.use("localhost", 6379) 9 | 10 | use_bear_store = create( 11 | { 12 | "bears": "🐻", 13 | }, 14 | lambda set, get: ( 15 | { 16 | "increase_bears": lambda: set( 17 | lambda state: {**state, "bears": state["bears"] + "🐻"} 18 | ), 19 | } 20 | ), 21 | ) 22 | 23 | increase_bears = use_bear_store(lambda state: state.increase_bears) 24 | 25 | 26 | @app.route("/") 27 | def get_bears(): 28 | increase_bears() 29 | return use_bear_store(lambda state: state.bears) 30 | 31 | 32 | if __name__ == "__main__": 33 | app.run(debug=True, host="0.0.0.0", port=9999) 34 | -------------------------------------------------------------------------------- /src/hooks/backends/async_interface.py: -------------------------------------------------------------------------------- 1 | from types import SimpleNamespace 2 | from typing import Any, TypeVar, Union 3 | 4 | T = TypeVar("T") 5 | 6 | 7 | class AsyncHooksBackend(SimpleNamespace): 8 | @classmethod 9 | async def use(cls, *args: Any, **kwargs: Any) -> Any: 10 | from .backend_state import set_hooks_backend 11 | 12 | set_hooks_backend(cls) 13 | return cls 14 | 15 | @classmethod 16 | async def load(cls, identifier: str) -> Any: 17 | raise NotImplemented 18 | 19 | @classmethod 20 | async def save(cls, identifier: str, value: Any) -> Union[bool, None, Any]: 21 | raise NotImplemented 22 | 23 | @classmethod 24 | async def exists(cls, identifier: str) -> bool: 25 | raise NotImplemented 26 | 27 | @classmethod 28 | async def reset_backend(cls) -> None: 29 | raise NotImplemented 30 | -------------------------------------------------------------------------------- /.github/release-drafter.yml: -------------------------------------------------------------------------------- 1 | # Release drafter configuration https://github.com/release-drafter/release-drafter#configuration 2 | # Emojis were chosen to match the https://gitmoji.carloscuesta.me/ 3 | 4 | name-template: "v$NEXT_PATCH_VERSION" 5 | tag-template: "v$NEXT_PATCH_VERSION" 6 | 7 | categories: 8 | - title: ":rocket: Features" 9 | labels: [enhancement, feature] 10 | - title: ":wrench: Fixes & Refactoring" 11 | labels: [bug, refactoring, bugfix, fix] 12 | - title: ":package: Build System & CI/CD" 13 | labels: [build, ci, testing] 14 | - title: ":boom: Breaking Changes" 15 | labels: [breaking] 16 | - title: ":pencil: Documentation" 17 | labels: [documentation] 18 | - title: ":arrow_up: Dependencies updates" 19 | labels: [dependencies] 20 | 21 | template: | 22 | ## What’s Changed 23 | 24 | $CHANGES 25 | 26 | ## :busts_in_silhouette: List of contributors 27 | 28 | $CONTRIBUTORS 29 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | default_language_version: 2 | python: python3.9 3 | 4 | default_stages: [commit, push] 5 | 6 | repos: 7 | - repo: https://github.com/pre-commit/pre-commit-hooks 8 | rev: v2.5.0 9 | hooks: 10 | - id: check-yaml 11 | - id: end-of-file-fixer 12 | exclude: LICENSE 13 | 14 | - repo: local 15 | hooks: 16 | - id: pyupgrade 17 | name: pyupgrade 18 | entry: poetry run pyupgrade --py39-plus 19 | types: [python] 20 | language: system 21 | 22 | - repo: local 23 | hooks: 24 | - id: isort 25 | name: isort 26 | entry: poetry run isort --settings-path pyproject.toml 27 | types: [python] 28 | language: system 29 | 30 | - repo: local 31 | hooks: 32 | - id: black 33 | name: black 34 | entry: poetry run black --config pyproject.toml 35 | types: [python] 36 | language: system 37 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: 🐛 Bug report 3 | about: If something isn't working 🔧 4 | title: '' 5 | labels: bug 6 | assignees: 7 | --- 8 | 9 | ## 🐛 Bug Report 10 | 11 | 12 | 13 | ## 🔬 How To Reproduce 14 | 15 | Steps to reproduce the behavior: 16 | 17 | 1. ... 18 | 19 | ### Code sample 20 | 21 | 22 | 23 | ### Environment 24 | 25 | * OS: [e.g. Linux / Windows / macOS] 26 | * Python version, get it with: 27 | 28 | ```bash 29 | python --version 30 | ``` 31 | 32 | ### Screenshots 33 | 34 | 35 | 36 | ## 📈 Expected behavior 37 | 38 | 39 | 40 | ## 📎 Additional context 41 | 42 | 43 | -------------------------------------------------------------------------------- /docs/async.md: -------------------------------------------------------------------------------- 1 | Lately, support in async / await has been added to Python Hooks. This allows you to write asynchronous code in your hooks. 2 | This is especially useful for hooks that need to make network requests or perform other I/O operations. 3 | 4 | To use async / await hooks, all you need to do is import your hooks from `hooks.asyncio.*` instead of `hooks.*`, 5 | and make sure that you are using an async backend, such as `hooks.plugins.redis_backend.AsyncRedisBackend`. 6 | 7 | For example: 8 | 9 | ```python 10 | from hooks.asyncio.use import use_state 11 | from hooks.plugins.redis_backend import AsyncRedisBackend 12 | 13 | ... 14 | 15 | await AsyncRedisBackend.use('localhost', 6789) 16 | 17 | async def use_async_state(): 18 | state, set_state = await use_state(0) 19 | await set_state(state + 1) 20 | return state 21 | 22 | ... 23 | 24 | print(await use_async_state()) # prints 0 25 | print(await use_async_state()) # prints 1 26 | ``` 27 | -------------------------------------------------------------------------------- /docs/plugins/zustand.md: -------------------------------------------------------------------------------- 1 | The Zustand plugin is a state management plugin that is inspired by the [Zustand](https://github.com/pmndrs/zustand) library. 2 | 3 | It is basically a wrapper around `use_context` to provide a more familiar API for Zustand users. 4 | 5 | ### Installation 6 | 7 | ```bash 8 | pip install python-hooks[zustand] 9 | ``` 10 | 11 | ### Usage 12 | 13 | ```python 14 | from hooks.plugins.zustand import create 15 | 16 | use_bear_store = create( 17 | { 18 | "bear": "🐻", 19 | }, 20 | lambda set, get: ( 21 | { 22 | "increase_bears": lambda: set(lambda state: {**state, "bear": "🐻🐻"}), 23 | } 24 | ), 25 | ) 26 | 27 | 28 | def test_basic_get_and_set() -> None: 29 | print(use_bear_store(lambda state: state.bear)) # Output: "🐻" 30 | 31 | increase_bears = use_bear_store(lambda state: state.increase_bears) 32 | increase_bears() 33 | 34 | print(use_bear_store(lambda state: state.bear)) # Output: "🐻🐻" 35 | ``` 36 | -------------------------------------------------------------------------------- /assets/images/coverage.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | coverage 17 | coverage 18 | 75% 19 | 75% 20 | 21 | 22 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | # Configuration: https://dependabot.com/docs/config-file/ 2 | # Docs: https://docs.github.com/en/github/administering-a-repository/keeping-your-dependencies-updated-automatically 3 | 4 | version: 2 5 | 6 | updates: 7 | - package-ecosystem: "pip" 8 | directory: "/" 9 | schedule: 10 | interval: "daily" 11 | allow: 12 | - dependency-type: "all" 13 | commit-message: 14 | prefix: ":arrow_up:" 15 | open-pull-requests-limit: 50 16 | 17 | - package-ecosystem: "github-actions" 18 | directory: "/" 19 | schedule: 20 | interval: "daily" 21 | allow: 22 | - dependency-type: "all" 23 | commit-message: 24 | prefix: ":arrow_up:" 25 | open-pull-requests-limit: 50 26 | 27 | - package-ecosystem: "docker" 28 | directory: "/docker" 29 | schedule: 30 | interval: "weekly" 31 | allow: 32 | - dependency-type: "all" 33 | commit-message: 34 | prefix: ":arrow_up:" 35 | open-pull-requests-limit: 50 36 | -------------------------------------------------------------------------------- /src/hooks/backends/memory_backend.py: -------------------------------------------------------------------------------- 1 | from typing import Any, TypeVar 2 | 3 | # Extend pickle to support lambdas 4 | import dill as pickle 5 | 6 | from .interface import HooksBackend 7 | 8 | T = TypeVar("T") 9 | BACKEND_KEY = "__hooks_backend__" 10 | 11 | 12 | class MemoryBackend(HooksBackend): 13 | @classmethod 14 | def load(cls, identifier: str) -> Any: 15 | return pickle.loads(globals().get(BACKEND_KEY + identifier)) 16 | 17 | @classmethod 18 | def save(cls, identifier: str, value: Any) -> bool: 19 | globals()[BACKEND_KEY + identifier] = pickle.dumps(value) 20 | return True 21 | 22 | @classmethod 23 | def exists(cls, identifier: str) -> bool: 24 | return BACKEND_KEY + identifier in globals() 25 | 26 | @classmethod 27 | def reset_backend(cls) -> None: 28 | keys = [] 29 | for key, value in globals().items(): 30 | if key.startswith(BACKEND_KEY): 31 | keys.append(key) 32 | for key in keys: 33 | del globals()[key] 34 | -------------------------------------------------------------------------------- /.idea/inspectionProfiles/Project_Default.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 22 | 23 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | Copyright (c) 2023 Amit Assaraf 3 | 4 | Permission is hereby granted, free of charge, to any person obtaining a copy 5 | of this software and associated documentation files (the "Software"), to deal 6 | in the Software without restriction, including without limitation the rights 7 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 8 | copies of the Software, and to permit persons to whom the Software is 9 | furnished to do so, subject to the following conditions: 10 | 11 | The above copyright notice and this permission notice shall be included in all 12 | copies or substantial portions of the Software. 13 | 14 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 15 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 16 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. 17 | IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, 18 | DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR 19 | OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE 20 | OR OTHER DEALINGS IN THE SOFTWARE. -------------------------------------------------------------------------------- /.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | name: build 2 | 3 | on: [push, pull_request] 4 | 5 | jobs: 6 | build: 7 | runs-on: ubuntu-latest 8 | strategy: 9 | matrix: 10 | python-version: ["3.9"] 11 | 12 | steps: 13 | - uses: actions/checkout@v2 14 | - name: Set up Python ${{ matrix.python-version }} 15 | uses: actions/setup-python@v2.2.2 16 | with: 17 | python-version: ${{ matrix.python-version }} 18 | 19 | - name: Install poetry 20 | run: make poetry-download 21 | 22 | - name: Set up cache 23 | uses: actions/cache@v2.1.6 24 | with: 25 | path: .venv 26 | key: venv-${{ matrix.python-version }}-${{ hashFiles('pyproject.toml') }}-${{ hashFiles('poetry.lock') }} 27 | - name: Install dependencies 28 | run: | 29 | poetry config virtualenvs.in-project true 30 | poetry install 31 | 32 | - name: Run style checks 33 | run: | 34 | make check-codestyle 35 | 36 | - name: Run tests 37 | run: | 38 | make test 39 | 40 | # Temporarily disabled until pytest solve their CVE 41 | # - name: Run safety checks 42 | # run: | 43 | # make check-safety 44 | -------------------------------------------------------------------------------- /src/hooks/backends/threadsafe_backend.py: -------------------------------------------------------------------------------- 1 | from typing import Any, TypeVar 2 | 3 | import threading 4 | 5 | # Extend pickle to support lambdas 6 | import dill as pickle 7 | 8 | from .interface import HooksBackend 9 | 10 | T = TypeVar("T") 11 | threading_local = threading.local() 12 | BACKEND_KEY = "__hooks_backend__" 13 | 14 | 15 | class ThreadsafeBackend(HooksBackend): 16 | @classmethod 17 | def load(cls, identifier: str) -> Any: 18 | return pickle.loads(getattr(threading_local, BACKEND_KEY + identifier, None)) 19 | 20 | @classmethod 21 | def save(cls, identifier: str, value: Any) -> bool: 22 | setattr(threading_local, BACKEND_KEY + identifier, pickle.dumps(value)) 23 | return True 24 | 25 | @classmethod 26 | def exists(cls, identifier: str) -> bool: 27 | return hasattr(threading_local, BACKEND_KEY + identifier) 28 | 29 | @classmethod 30 | def reset_backend(cls) -> None: 31 | keys = [] 32 | for key, value in threading_local.__dict__.items(): 33 | if key.startswith(BACKEND_KEY): 34 | keys.append(key) 35 | for key in keys: 36 | del threading_local.__dict__[key] 37 | -------------------------------------------------------------------------------- /docs/getting_started.md: -------------------------------------------------------------------------------- 1 | # 🚀 Getting Started 2 | 3 | In order to get started with Python Hooks, you need to install the library using pip, note that when installing 4 | the library you install it as `python-hooks` and not `hooks` as the latter is already taken in pip. 5 | 6 | ```bash 7 | pip install python-hooks 8 | ``` 9 | 10 | Once installed you can start using the library, the library is built with plugins support and you can use the built-in plugins or create your own. 11 | 12 | ```py 13 | from hooks import use_state, use_effect 14 | 15 | def my_stateful_function(): 16 | count, set_count = use_state(0) 17 | use_effect(lambda: print("First run"), []) 18 | use_effect(lambda: print(f"Count, {count}"), [count]) # (1) 19 | set_count(count + 1) 20 | return count 21 | 22 | my_stateful_function() # prints "First run" and "Count, 0" 23 | my_stateful_function() # prints "Count, 1" 24 | my_stateful_function() # prints "Count, 2" 25 | ``` 26 | 27 | 1. 🤩 Just like React, use_effect will only run if it's dependencies have changed. In this case, the dependency is the count variable. 28 | This is a very powerful feature as it allows you to control when your code runs. 29 | 30 | 31 | See [Hooks](hooks/base_hooks/use_state.md) to begin using the built-in hooks. 32 | -------------------------------------------------------------------------------- /SECURITY.md: -------------------------------------------------------------------------------- 1 | # Security 2 | 3 | ## 🔐 Reporting Security Issues 4 | 5 | > Do not open issues that might have security implications! 6 | > It is critical that security related issues are reported privately so we have time to address them before they become public knowledge. 7 | 8 | Vulnerabilities can be reported by emailing core members: 9 | 10 | - Amit Assaraf [amit.assaraf@gmail.com](mailto:amit.assaraf@gmail.com) 11 | 12 | Please include the requested information listed below (as much as you can provide) to help us better understand the nature and scope of the possible issue: 13 | 14 | - Type of issue (e.g. buffer overflow, SQL injection, cross-site scripting, etc.) 15 | - Full paths of source file(s) related to the manifestation of the issue 16 | - The location of the affected source code (tag/branch/commit or direct URL) 17 | - Any special configuration required to reproduce the issue 18 | - Environment (e.g. Linux / Windows / macOS) 19 | - Step-by-step instructions to reproduce the issue 20 | - Proof-of-concept or exploit code (if possible) 21 | - Impact of the issue, including how an attacker might exploit the issue 22 | 23 | This information will help us triage your report more quickly. 24 | 25 | ## Preferred Languages 26 | 27 | We prefer all communications to be in English. 28 | -------------------------------------------------------------------------------- /tests/test_hooks/test_use_context.py: -------------------------------------------------------------------------------- 1 | from hooks.asyncio.use import create_context as create_async_context 2 | from hooks.asyncio.use import set_context_value as async_set_context_value 3 | from hooks.asyncio.use import use_context as async_use_context 4 | from hooks.use import create_context, set_context_value, use_context 5 | 6 | 7 | def test_context_hook() -> None: 8 | context = create_context("foo") 9 | 10 | def flip_according_to_context_value() -> str: 11 | foo_or_bar = use_context(context) 12 | if foo_or_bar == "foo": 13 | return "bar" 14 | return "foo" 15 | 16 | assert flip_according_to_context_value() == "bar" 17 | set_context_value(context, "bar") 18 | assert flip_according_to_context_value() == "foo" 19 | 20 | 21 | async def test_context_hook_async(async_backend) -> None: 22 | context = await create_async_context("foo") 23 | 24 | async def flip_according_to_context_value() -> str: 25 | foo_or_bar = await async_use_context(context) 26 | if foo_or_bar == "foo": 27 | return "bar" 28 | return "foo" 29 | 30 | assert await flip_according_to_context_value() == "bar" 31 | await async_set_context_value(context, "bar") 32 | assert await flip_according_to_context_value() == "foo" 33 | -------------------------------------------------------------------------------- /tests/test_hooks/test_performance.py: -------------------------------------------------------------------------------- 1 | from cProfile import Profile 2 | from pstats import SortKey, Stats 3 | from statistics import median 4 | from timeit import Timer, timeit 5 | 6 | from hooks.use import use_state 7 | 8 | 9 | class Foo: 10 | def local_state(self) -> int: 11 | counter, set_counter = use_state(0) 12 | set_counter(counter + 1) 13 | return counter 14 | 15 | 16 | class Bar: 17 | def __init__(self): 18 | self.counter = 0 19 | 20 | def python_state(self) -> int: 21 | self.counter += 1 22 | return self.counter 23 | 24 | 25 | def test_local_state() -> None: 26 | python_state = Timer(Bar().python_state).repeat(repeat=100000, number=1) 27 | hooks_state = Timer(Foo().local_state).repeat(repeat=100000, number=1) 28 | python_state_median = median(python_state) 29 | hooks_state_median = median(hooks_state) 30 | 31 | overhead = hooks_state_median / python_state_median 32 | 33 | allowed_overhead: int = 30 34 | 35 | if overhead > allowed_overhead: 36 | with Profile() as profile: 37 | timeit(Foo().local_state, number=100000) 38 | (Stats(profile).strip_dirs().sort_stats(SortKey.TIME).print_stats()) 39 | assert overhead < allowed_overhead, "Performance is not good enough" 40 | -------------------------------------------------------------------------------- /.github/PULL_REQUEST_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | ## Description 2 | 3 | 4 | 5 | ## Related Issue 6 | 7 | 8 | 9 | ## Type of Change 10 | 11 | 12 | 13 | - [ ] 📚 Examples / docs / tutorials / dependencies update 14 | - [ ] 🔧 Bug fix (non-breaking change which fixes an issue) 15 | - [ ] 🥂 Improvement (non-breaking change which improves an existing feature) 16 | - [ ] 🚀 New feature (non-breaking change which adds functionality) 17 | - [ ] 💥 Breaking change (fix or feature that would cause existing functionality to change) 18 | - [ ] 🔐 Security fix 19 | 20 | ## Checklist 21 | 22 | 23 | 24 | - [ ] I've read the [`CODE_OF_CONDUCT.md`](https://github.com/amitassaraf/python-hooks/blob/master/CODE_OF_CONDUCT.md) document. 25 | - [ ] I've read the [`CONTRIBUTING.md`](https://github.com/amitassaraf/python-hooks/blob/master/CONTRIBUTING.md) guide. 26 | - [ ] I've updated the code style using `make codestyle`. 27 | - [ ] I've written tests for all new methods and classes that I created. 28 | - [ ] I've written the docstring in Google format for all the methods and classes that I used. 29 | -------------------------------------------------------------------------------- /docs/plugins/redux.md: -------------------------------------------------------------------------------- 1 | The Redux plugin is a state management plugin that provides a familiar API for Redux users. 2 | 3 | It provides one global store that can be used by any function to get and set state. 4 | 5 | ### Installation 6 | 7 | ```bash 8 | pip install python-hooks[redux] 9 | ``` 10 | 11 | ### Usage 12 | 13 | ```python 14 | from hooks import combine_reducers 15 | from hooks.plugins.redux import create_redux_store, use_dispatch, use_selector 16 | 17 | 18 | def tasks_reducer(current_state: dict, action: dict) -> dict: 19 | if action["type"] == "ADD_TASK": 20 | return {"tasks": current_state["tasks"] + [action["task"]]} 21 | return current_state 22 | 23 | 24 | def user_reducer(current_state: dict, action: dict) -> dict: 25 | if action["type"] == "SET_USER": 26 | return {"user": action["user"]} 27 | return current_state 28 | 29 | 30 | combined_reducer = combine_reducers(tasks_reducer, user_reducer) 31 | create_redux_store(combined_reducer, {"tasks": [], "user": None}) 32 | 33 | 34 | def test_basic_get_and_set() -> None: 35 | dispatch = use_dispatch() 36 | print(use_selector(lambda state: state.tasks)) # Output: [] 37 | dispatch({"type": "ADD_TASK", "task": "Do the dishes"}) 38 | print(use_selector(lambda state: state.tasks)) # Output: ["Do the dishes"] 39 | ``` 40 | -------------------------------------------------------------------------------- /tests/conftest.py: -------------------------------------------------------------------------------- 1 | from typing import Any 2 | 3 | import asyncio 4 | 5 | # Extend pickle to support lambdas 6 | import dill as pickle 7 | import pytest 8 | 9 | from hooks.backends.async_interface import AsyncHooksBackend 10 | from hooks.backends.memory_backend import MemoryBackend 11 | 12 | 13 | @pytest.fixture() 14 | async def async_backend(): 15 | BACKEND_KEY = "__hooks_backend__" 16 | 17 | class AsyncMemoryBackend(AsyncHooksBackend): 18 | @classmethod 19 | async def load(cls, identifier: str) -> Any: 20 | return pickle.loads(globals().get(BACKEND_KEY + identifier)) 21 | 22 | @classmethod 23 | async def save(cls, identifier: str, value: Any) -> bool: 24 | globals()[BACKEND_KEY + identifier] = pickle.dumps(value) 25 | return True 26 | 27 | @classmethod 28 | async def exists(cls, identifier: str) -> bool: 29 | return BACKEND_KEY + identifier in globals() 30 | 31 | @classmethod 32 | async def reset_backend(cls) -> None: 33 | keys = [] 34 | for key, value in globals().items(): 35 | if key.startswith(BACKEND_KEY): 36 | keys.append(key) 37 | for key in keys: 38 | del globals()[key] 39 | 40 | await AsyncMemoryBackend.use() 41 | yield 42 | MemoryBackend.use() 43 | -------------------------------------------------------------------------------- /docs/backends/default.md: -------------------------------------------------------------------------------- 1 | Under the hood, Python Hooks uses backend objects to store the state of hooks. This provides a way to persist state 2 | in different ways by using different backends. By default, Python Hooks uses the `MemoryBackend` to store state in 3 | memory, but you can also use the `RedisBackend` to store state in Redis, or you can create your own custom backend 4 | to store state in any way you want. 5 | 6 | #### Backends available out of the box: 7 | * MemoryBackend - Pickled state stored in memory 8 | * RedisBackend - Pickled state stored in Redis 9 | * ThreadsafeBackend - Pickled state stored in a thread local data structure 10 | 11 | --- 12 | 13 | ### Changing the default backend 14 | 15 | Changing the in-use backend is super easy. 16 | 17 | ```python 18 | from hooks.backends import ThreadsafeBackend 19 | 20 | ThreadsafeBackend.use() 21 | 22 | # Any hooks created after this point will use the ThreadsafeBackend 23 | ``` 24 | 25 | or for using Redis: 26 | 27 | ```python 28 | from hooks.backends import RedisBackend 29 | 30 | RedisBackend.use("localhost", 6379, db=0) 31 | 32 | # Any hooks created after this point will use the RedisBackend 33 | ``` 34 | 35 | --- 36 | 37 | ### Creating a custom backend 38 | 39 | You can create a custom backend by just inheriting from the `HooksBackend` class and implementing subset of the methods 40 | defined in the `HooksBackend` class. 41 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # How to contribute 2 | 3 | ## Dependencies 4 | 5 | We use `poetry` to manage the [dependencies](https://github.com/python-poetry/poetry). 6 | If you dont have `poetry`, you should install with `make poetry-download`. 7 | 8 | To install dependencies and prepare [`pre-commit`](https://pre-commit.com/) hooks you would need to run `install` command: 9 | 10 | ```bash 11 | make install 12 | make pre-commit-install 13 | ``` 14 | 15 | To activate your `virtualenv` run `poetry shell`. 16 | 17 | ## Codestyle 18 | 19 | After installation you may execute code formatting. 20 | 21 | ```bash 22 | make codestyle 23 | ``` 24 | 25 | ### Checks 26 | 27 | Many checks are configured for this project. Command `make check-codestyle` will check black, isort and darglint. 28 | The `make check-safety` command will look at the security of your code. 29 | 30 | Comand `make lint` applies all checks. 31 | 32 | ### Before submitting 33 | 34 | Before submitting your code please do the following steps: 35 | 36 | 1. Add any changes you want 37 | 1. Add tests for the new changes 38 | 1. Edit documentation if you have changed something significant 39 | 1. Run `make codestyle` to format your changes. 40 | 1. Run `make lint` to ensure that types, security and docstrings are okay. 41 | 42 | ## Other help 43 | 44 | You can contribute by spreading a word about this library. 45 | It would also be a huge contribution to write 46 | a short article on how you are using this project. 47 | You can also share your best practices with us. 48 | -------------------------------------------------------------------------------- /docs/hooks/additional_hooks/custom_hooks.md: -------------------------------------------------------------------------------- 1 | Sometimes it would make sense to write custom hooks. For example, you may want to create a custom hook to fetch data 2 | from a database or to fetch data from a file. Or you might want to write a custom hook that combines functionality from 3 | multiple hooks. 4 | 5 | To do this, it's very simple. Just write a function that it's name starts with `use_` and it will be treated as a hook. 6 | For example, let's say we want to write a custom hook that combines the functionality of `use_state` and `use_effect`: 7 | 8 | ```python 9 | from hooks import use_state, use_effect 10 | from hooks.utils import destruct 11 | 12 | 13 | def use_state_and_effect(initial_state: int): 14 | state, set_state = use_state(initial_state) 15 | 16 | def effect(): 17 | print("State changed: ", state) 18 | 19 | use_effect(effect, [state]) 20 | 21 | # Returning in a dict just as an example for using destruct (The util will work on any object) 22 | return { 23 | "state": state, 24 | "set_state": set_state 25 | } 26 | 27 | 28 | set_state = destruct(use_state_and_effect(0))["set_state"] 29 | set_state(1) 30 | set_state(2) 31 | set_state(3) 32 | ``` 33 | 34 | __Note__: You have to use the `use_` prefix in order for the hook to be treated as a hook. Otherwise, it will be treated 35 | as a normal function and will not be able to identify where to store the state inside the backend. 36 | 37 | --- 38 | ### Next steps 39 | 40 | Learn about scoping hooks with [hooks_scope](../scoping/scope_decorator.md) decorator. 41 | -------------------------------------------------------------------------------- /src/hooks/plugins/redux.py: -------------------------------------------------------------------------------- 1 | from typing import Any, Callable 2 | 3 | from box import Box 4 | 5 | from hooks import use_reducer 6 | 7 | global _redux_root_reducer 8 | 9 | 10 | def create_redux_store( 11 | reducer: Callable[[dict[str, Any], dict[str, Any]], dict[str, Any]], 12 | initial_state: dict[str, Any], 13 | ) -> None: 14 | """ 15 | Initialize the redux store. This must be called before any other redux functions are used. 16 | :param reducer: The reducer to use 17 | :param initial_state: The initial state to use 18 | :return: None 19 | """ 20 | global _redux_root_reducer 21 | use_reducer(reducer, initial_state, []) 22 | _redux_root_reducer = reducer 23 | 24 | 25 | def use_selector(callable: Callable[[dict[str, Any]], Any]) -> Any: 26 | """ 27 | Use a selector to get a value from the redux store. 28 | :param callable: The selector to use 29 | :return: The value from the selector 30 | """ 31 | global _redux_root_reducer 32 | if not _redux_root_reducer: 33 | raise Exception("Redux store not initialized") 34 | 35 | state, _ = use_reducer(_redux_root_reducer) 36 | return callable(Box(state)) 37 | 38 | 39 | def use_dispatch() -> Callable[[dict[str, Any]], dict[str, Any]]: 40 | """ 41 | Get the dispatch function from the redux store. 42 | :return: The dispatch function 43 | """ 44 | global _redux_root_reducer 45 | if not _redux_root_reducer: 46 | raise Exception("Redux store not initialized") 47 | 48 | _, dispatch = use_reducer(_redux_root_reducer) 49 | return dispatch 50 | -------------------------------------------------------------------------------- /src/hooks/plugins/zustand.py: -------------------------------------------------------------------------------- 1 | # mypy: ignore-errors 2 | 3 | from typing import Any, Callable 4 | 5 | from box import Box 6 | 7 | from hooks import create_context, set_context_value, use_context 8 | 9 | StateSelector = Callable[[Any], Any] 10 | SetTyping = Callable[[StateSelector], None] 11 | GetTyping = Callable[[], Box] 12 | 13 | 14 | class ZustandStore: 15 | def __init__( 16 | self, 17 | store_initial_state: dict[str, Any], 18 | store_config: Callable[[SetTyping, GetTyping], Any], 19 | ): 20 | self.state_context = create_context(store_initial_state) 21 | self.config_context = create_context({}) 22 | 23 | def setter(state_selector: StateSelector) -> None: 24 | set_context_value( 25 | self.state_context, 26 | state_selector(use_context(self.state_context)), 27 | ) 28 | 29 | def getter() -> Box: 30 | return Box( 31 | {**use_context(self.state_context), **use_context(self.config_context)} 32 | ) 33 | 34 | self.setter = setter 35 | self.getter = getter 36 | set_context_value( 37 | self.config_context, 38 | store_config(self.setter, self.getter), 39 | ) 40 | 41 | def __call__(self, selector: StateSelector) -> Any: 42 | return selector(self.getter()) 43 | 44 | 45 | def create( 46 | store_initial_state: dict[str, Any], 47 | store_config: Callable[[SetTyping, GetTyping], Any], 48 | ) -> ZustandStore: 49 | """ """ 50 | return ZustandStore(store_initial_state, store_config) 51 | -------------------------------------------------------------------------------- /src/hooks/backends/python_objects_backend.py: -------------------------------------------------------------------------------- 1 | from typing import Any, TypeVar 2 | 3 | from functools import lru_cache 4 | 5 | from .async_interface import AsyncHooksBackend 6 | from .interface import HooksBackend 7 | 8 | T = TypeVar("T") 9 | 10 | 11 | @lru_cache(maxsize=None) 12 | def python_object_backend_factory(wrapped_cls: type[T]) -> type[HooksBackend]: 13 | class PythonObjectHooksBackend(HooksBackend): 14 | @classmethod 15 | def load(cls, identifier: str) -> Any: 16 | return getattr(wrapped_cls, identifier) 17 | 18 | @classmethod 19 | def save(cls, identifier: str, value: Any) -> bool: 20 | setattr(wrapped_cls, identifier, value) 21 | return True 22 | 23 | @classmethod 24 | def exists(cls, identifier: str) -> bool: 25 | return hasattr(wrapped_cls, identifier) 26 | 27 | return PythonObjectHooksBackend 28 | 29 | 30 | @lru_cache(maxsize=None) 31 | def async_python_object_backend_factory( 32 | wrapped_cls: type[T], 33 | ) -> type[AsyncHooksBackend]: 34 | class AsyncPythonObjectHooksBackend(AsyncHooksBackend): 35 | @classmethod 36 | async def load(cls, identifier: str) -> Any: 37 | return getattr(wrapped_cls, identifier) 38 | 39 | @classmethod 40 | async def save(cls, identifier: str, value: Any) -> bool: 41 | setattr(wrapped_cls, identifier, value) 42 | return True 43 | 44 | @classmethod 45 | async def exists(cls, identifier: str) -> bool: 46 | return hasattr(wrapped_cls, identifier) 47 | 48 | return AsyncPythonObjectHooksBackend 49 | -------------------------------------------------------------------------------- /tests/test_plugins/test_redux.py: -------------------------------------------------------------------------------- 1 | from hooks import combine_reducers 2 | from hooks.plugins.redux import create_redux_store, use_dispatch, use_selector 3 | from hooks.plugins.redux_async import create_redux_store as create_async_redux_store 4 | from hooks.plugins.redux_async import use_dispatch as use_async_dispatch 5 | from hooks.plugins.redux_async import use_selector as use_async_selector 6 | 7 | 8 | def tasks_reducer(current_state: dict, action: dict) -> dict: 9 | if action["type"] == "ADD_TASK": 10 | return {"tasks": current_state["tasks"] + [action["task"]]} 11 | return current_state 12 | 13 | 14 | def user_reducer(current_state: dict, action: dict) -> dict: 15 | if action["type"] == "SET_USER": 16 | return {"user": action["user"]} 17 | return current_state 18 | 19 | 20 | combined_reducer = combine_reducers(tasks_reducer, user_reducer) 21 | create_redux_store(combined_reducer, {"tasks": [], "user": None}) 22 | 23 | 24 | def test_basic_get_and_set() -> None: 25 | dispatch = use_dispatch() 26 | assert use_selector(lambda state: state.tasks) == [] 27 | dispatch({"type": "ADD_TASK", "task": "Do the dishes"}) 28 | assert use_selector(lambda state: state.tasks) == ["Do the dishes"] 29 | 30 | 31 | async def test_basic_get_and_set_async(async_backend) -> None: 32 | await create_async_redux_store(combined_reducer, {"tasks": [], "user": None}) 33 | 34 | dispatch = await use_async_dispatch() 35 | assert await use_async_selector(lambda state: state.tasks) == [] 36 | await dispatch({"type": "ADD_TASK", "task": "Do the dishes"}) 37 | assert await use_async_selector(lambda state: state.tasks) == ["Do the dishes"] 38 | -------------------------------------------------------------------------------- /mkdocs.yml: -------------------------------------------------------------------------------- 1 | site_name: Python Hooks 2 | theme: 3 | logo: images/favicon.ico 4 | name: material 5 | favicon: images/favicon.ico 6 | palette: 7 | scheme: slate 8 | primary: red 9 | accent: pink 10 | features: 11 | - content.code.copy 12 | - content.code.annotate 13 | - navigation.expand 14 | nav: 15 | - Overview: index.md 16 | - Inspiration: inspiration.md 17 | - Getting started: getting_started.md 18 | - 'Hooks': 19 | - 'Base Hooks': 20 | - use_state Hook: hooks/base_hooks/use_state.md 21 | - use_effect Hook: hooks/base_hooks/use_effect.md 22 | - use_context Hook: hooks/base_hooks/use_context.md 23 | - 'Additional Hooks': 24 | - use_reducer Hook: hooks/additional_hooks/use_reducer.md 25 | - Custom Hooks: hooks/additional_hooks/custom_hooks.md 26 | - 'Scoping': 27 | - Default Scoping: scoping/default_scoping.md 28 | - Scope Decorator: scoping/scope_decorator.md 29 | - 'Backends': 30 | - What are backends: backends/default.md 31 | - 'Plugins': 32 | - Zustand: plugins/zustand.md 33 | - Redux: plugins/redux.md 34 | - Async Support: async.md 35 | 36 | markdown_extensions: 37 | - pymdownx.highlight: 38 | anchor_linenums: true 39 | line_spans: __span 40 | pygments_lang_class: true 41 | - pymdownx.inlinehilite 42 | - pymdownx.snippets 43 | - pymdownx.superfences 44 | 45 | extra: 46 | generator: false 47 | social: 48 | - icon: fontawesome/brands/github 49 | link: https://github.com/amitassaraf/python-hooks 50 | copyright: Copyright © 2023 Amit Assaraf 51 | repo_url: https://github.com/amitassaraf/python-hooks 52 | repo_name: amitassaraf/python-hooks 53 | -------------------------------------------------------------------------------- /src/hooks/plugins/redux_async.py: -------------------------------------------------------------------------------- 1 | from typing import Any, Callable 2 | 3 | from collections.abc import Awaitable 4 | 5 | from box import Box 6 | 7 | from hooks.asyncio.reducers import use_reducer 8 | 9 | global _redux_root_reducer 10 | 11 | 12 | async def create_redux_store( 13 | reducer: Callable[[dict[str, Any], dict[str, Any]], dict[str, Any]], 14 | initial_state: dict[str, Any], 15 | ) -> None: 16 | """ 17 | Initialize the redux store. This must be called before any other redux functions are used. 18 | :param reducer: The reducer to use 19 | :param initial_state: The initial state to use 20 | :return: None 21 | """ 22 | global _redux_root_reducer 23 | await use_reducer(reducer, initial_state, []) 24 | _redux_root_reducer = reducer 25 | 26 | 27 | async def use_selector(callable: Callable[[dict[str, Any]], Any]) -> Any: 28 | """ 29 | Use a selector to get a value from the redux store. 30 | :param callable: The selector to use 31 | :return: The value from the selector 32 | """ 33 | global _redux_root_reducer 34 | if not _redux_root_reducer: 35 | raise Exception("Redux store not initialized") 36 | 37 | state, _ = await use_reducer(_redux_root_reducer) 38 | return callable(Box(state)) 39 | 40 | 41 | async def use_dispatch() -> Callable[[dict[str, Any]], Awaitable[dict[str, Any]]]: 42 | """ 43 | Get the dispatch function from the redux store. 44 | :return: The dispatch function 45 | """ 46 | global _redux_root_reducer 47 | if not _redux_root_reducer: 48 | raise Exception("Redux store not initialized") 49 | 50 | _, dispatch = await use_reducer(_redux_root_reducer) 51 | return dispatch 52 | -------------------------------------------------------------------------------- /src/hooks/utils.py: -------------------------------------------------------------------------------- 1 | from typing import Any, Union 2 | 3 | 4 | class __Destruct: 5 | def __init__(self, wrapped_object: Any) -> None: 6 | self.wrapped_object = wrapped_object 7 | 8 | def __getitem__(self, items: Any) -> Union[Any, tuple, None]: 9 | if not isinstance(items, (list, tuple)): 10 | items = [items] 11 | 12 | output = [] 13 | for item in items: 14 | try: 15 | output.append(self.wrapped_object[item]) 16 | except ( 17 | IndexError, 18 | KeyError, 19 | TypeError, 20 | ): 21 | if isinstance(self.wrapped_object, (dict,)): 22 | output.append(self.wrapped_object.get(item)) 23 | elif hasattr(self.wrapped_object, str(item)): 24 | output.append(getattr(self.wrapped_object, str(item))) 25 | if len(output) == 1: 26 | return output[0] 27 | elif len(output) == 0: 28 | return None 29 | return tuple(output) 30 | 31 | 32 | def destruct(wrapped_object: Any) -> __Destruct: 33 | """ 34 | A utility function to allow destructuring of objects in a similar way to JavaScript. For example: 35 | const {name} = useData({name: "John", count: 0}) 36 | 37 | This is equivalent to: 38 | name = destruct(useData({name: "John", count: 0}))["name"] 39 | 40 | :param wrapped_object: The object to destructure 41 | :return: A __Destruct object that can be used to destructure the wrapped object 42 | """ 43 | return __Destruct(wrapped_object) 44 | 45 | 46 | # Alias for destruct 47 | d = destruct 48 | 49 | 50 | # Async lambda 51 | def alambda(async_iterator): 52 | return async_iterator.__anext__() 53 | -------------------------------------------------------------------------------- /docs/inspiration.md: -------------------------------------------------------------------------------- 1 | # Inspiration 2 | 3 | I'll begin be prefacing that this project will not fit everyone's needs. It's a very opinionated project and it's not meant to be a replacement for any of the existing state management libraries. 4 | Additionally, if you are trying to squeeze the most performance out of Python this is probably not the right tool for you _(Though you might also be using the wrong programming language)_. 5 | 6 | The goal of the project is to provide a simple and familiar way to manage state in Python. It's inspired by the React hooks API and it's meant to be used in a similar way. It can be used to manage 7 | state in a single function or in a large project. The state can be stored in memory, in a database or in a file. It's up to you to decide and the library is easily extendable to support any backend. 8 | 9 | One of the inspirations for this project was seeing the way junior developers struggle with state management and program structure in Python. This library is intended to provide a sense of familiarity 10 | to developers coming from the JavaScript world and to provide a simple way to manage state in Python which will not require a lot of boilerplate code or hurt performance. 11 | 12 | It is meant to help write better data-driven code and to help developers focus on the business logic of their code rather than on the plumbing. 13 | 14 | 15 | [**Discord**](https://discord.gg/mayParnv) | [**Github**](https://github.com/amitassaraf/python-hooks) 16 | 17 | 18 | 19 | Note: This project is still in beta and is not recommended for production use just yet. We are working on adding more tests and improving the documentation. If you are interested in contributing, please reach out to me on [Discord](https://discord.gg/mayParnv). 20 | 21 | See [Getting started](getting_started.md) for a quick tutorial on how to use the library. 22 | -------------------------------------------------------------------------------- /docs/index.md: -------------------------------------------------------------------------------- 1 | # Overview 2 | ### Python Hooks, a React inspired way to code in Python. 3 | 4 | 5 |
6 | 7 | 8 | GitHub Contributors 9 | 10 | 11 | Issues 12 | 13 | 14 | GitHub pull requests 15 | 16 | Dev status 17 | 18 |
19 | 20 | 21 |
22 | drawing 23 |
24 |
25 |
26 | 27 | * ⚡️ Written with performance in mind __(Not using inspect module)__ 28 | * 🐍 Support for familiar React like hooks such as `use_state`, `use_effect`, `use_reducer`, `use_context` and more. 29 | * 🛠️ Built with plugins support and easy to extend. 30 | * 🤝 Python 3.9+ compatibility 31 | * 🪶 Lightweight to remove the need for a lot of boilerplate code. 32 | * 📦 Support for different backend state-stores. 33 | * 🔧 Support for hooks-state scoping. 34 | * 🌎 Redis and Zustand plugins out of the box. 35 | * 🔌 Tested mainly on CPython. 36 | 37 | --- 38 | 39 | ## API Example 40 | 41 | As Python is commonly used to create APIs, I wanted to provide one real-world example of using hooks for state management in Flask. Note, that this is just one example and the library can be used in many other ways. See [examples](https://github.com/amitassaraf/python-hooks/tree/master/examples) 42 | for more examples. 43 | 44 | drawing 45 | 46 | 47 | See [Getting started](getting_started.md) for a quick tutorial on how to use the library. 48 | 49 | See [Inspiration](inspiration.md) for a more in-depth explanation of the motivation behind this project. 50 | 51 |
52 | 53 | Made with love by Amit Assaraf 54 | 55 | -------------------------------------------------------------------------------- /tests/test_hooks/test_use_effect.py: -------------------------------------------------------------------------------- 1 | from unittest.mock import Mock 2 | 3 | from hooks import use_effect 4 | from hooks.asyncio.use import use_effect as async_use_effect 5 | 6 | 7 | def test_basic_use() -> None: 8 | mock = Mock() 9 | 10 | def my_stateful_function() -> None: 11 | use_effect(lambda: mock(), []) 12 | return 13 | 14 | my_stateful_function() 15 | my_stateful_function() 16 | my_stateful_function() 17 | 18 | assert mock.call_count == 1 19 | 20 | 21 | def test_use_with_dependencies() -> None: 22 | mock = Mock() 23 | 24 | def my_stateful_function(name: str) -> None: 25 | use_effect(lambda: mock(), [name]) 26 | return 27 | 28 | my_stateful_function("John") 29 | my_stateful_function("John") 30 | my_stateful_function("Jane") 31 | 32 | assert mock.call_count == 2 33 | 34 | 35 | def test_use_as_decorator() -> None: 36 | mock = Mock() 37 | 38 | def my_stateful_function() -> None: 39 | @use_effect(dependencies=[], decorating=True) 40 | def my_effect() -> None: 41 | return mock() 42 | 43 | return 44 | 45 | my_stateful_function() 46 | my_stateful_function() 47 | my_stateful_function() 48 | 49 | assert mock.call_count == 1 50 | 51 | 52 | def test_use_as_decorator_with_dependencies() -> None: 53 | mock = Mock() 54 | 55 | def my_stateful_function(name: str) -> None: 56 | @use_effect(dependencies=[name], decorating=True) 57 | def my_effect() -> None: 58 | return mock() 59 | 60 | return 61 | 62 | my_stateful_function("John") 63 | my_stateful_function("John") 64 | my_stateful_function("Jane") 65 | 66 | assert mock.call_count == 2 67 | 68 | 69 | async def test_basic_use_async(async_backend) -> None: 70 | mock = Mock() 71 | 72 | async def my_stateful_function() -> None: 73 | await async_use_effect(lambda: mock(), []) 74 | return 75 | 76 | await my_stateful_function() 77 | await my_stateful_function() 78 | await my_stateful_function() 79 | 80 | assert mock.call_count == 1 81 | 82 | 83 | async def test_use_with_dependencies_async(async_backend) -> None: 84 | mock = Mock() 85 | 86 | async def my_stateful_function(name: str) -> None: 87 | await async_use_effect(lambda: mock(), [name]) 88 | return 89 | 90 | await my_stateful_function("John") 91 | await my_stateful_function("John") 92 | await my_stateful_function("Jane") 93 | 94 | assert mock.call_count == 2 95 | -------------------------------------------------------------------------------- /docs/hooks/base_hooks/use_context.md: -------------------------------------------------------------------------------- 1 | The `use_context` hook allows you to access context from anywhere in your program. This is useful for accessing global state. 2 | 3 | Differentiating from the rest of the basic hooks, `use_context` comes along with two other functions `create_context` and `set_context_value`. 4 | 5 | `use_context` is used to access the context value. 6 | `create_context` is used to create a context object. 7 | `set_context_value` is used to set the context value. 8 | 9 | Context, like all hooks, is affected by scoping. This means that context is only accessible within the scope of the function that created (!) it. 10 | Read more about scoping hooks with [hooks_scope](../scoping/scope_decorator.md) decorator. 11 | 12 | --- 13 | ### Creating and using a context 14 | 15 | ```py 16 | from hooks import create_context, use_context 17 | 18 | my_context = create_context("Initial value") 19 | 20 | print(use_context(my_context)) # Output: Initial value 21 | ``` 22 | 23 | This is a simple example of creating a context and accessing it with `use_context`. Pretty boring, right? 24 | Let's make it more interesting. 25 | 26 | --- 27 | ### Setting a context value 28 | 29 | ```py 30 | from hooks import create_context, use_context, set_context_value 31 | 32 | my_context = create_context("Initial value") 33 | 34 | print(use_context(my_context)) # Output: Initial value 35 | 36 | set_context_value(my_context, "New value") 37 | 38 | print(use_context(my_context)) # Output: New value 39 | ``` 40 | 41 | Okay, so now we know how to set a context value. But this is still pretty boring. Let's see how we can use this in a 42 | real program. 43 | 44 | --- 45 | ### Using context in a real program 46 | 47 | ```py 48 | from hooks import create_context, use_context, set_context_value 49 | 50 | my_context = create_context("Initial value") 51 | 52 | def my_stateful_function() -> None: 53 | print(use_context(my_context)) 54 | return 55 | 56 | my_stateful_function() # Output: Initial value 57 | 58 | set_context_value(my_context, "New value") 59 | 60 | my_stateful_function() # Output: New value 61 | ``` 62 | 63 | Now this is a bit more interesting. We can see that the context value is updated when we call `set_context_value` and 64 | that the value is accessible from anywhere in the program. 65 | 66 | --- 67 | ### Why is this useful? 68 | 69 | Context is useful for storing global state. This is useful for storing things like the current user, the current theme, 70 | or the current language. 71 | 72 | It is also used as a building block for plugins like `Zustand` and `Redux`. 73 | 74 | ### Next steps 75 | 76 | Learn about [additional hooks](../additional_hooks/use_reducer.md). 77 | 78 | Learn about scoping hooks with [hooks_scope](../scoping/scope_decorator.md) decorator. 79 | -------------------------------------------------------------------------------- /docs/hooks/base_hooks/use_effect.md: -------------------------------------------------------------------------------- 1 | The `use_effect` hook allows us to run code when the state of the program changes. This is useful for running code when 2 | the program starts, or when the user interacts with the program, or it's dependencies change. 3 | 4 | --- 5 | 6 | ### Basic use 7 | 8 | ```py 9 | use_effect(lambda: print("Hello World"), []) 10 | ``` 11 | 12 | The `use_effect` hook takes two arguments. The first argument is a function which will be called when the state of the 13 | program changes. 14 | The second argument is a list of dependencies. The function will only be called when the dependencies change and at 15 | least once when the program starts. 16 | 17 | Let's see an example: 18 | 19 | ```py 20 | from hooks import use_effect 21 | 22 | 23 | def my_stateful_function() -> None: 24 | # Will only print "Hello World" once, during the first function call 25 | use_effect(lambda: print("Hello World"), []) 26 | return 27 | 28 | 29 | my_stateful_function() # Output: Hello World 30 | my_stateful_function() # Output: 31 | my_stateful_function() # Output: 32 | ``` 33 | 34 | --- 35 | 36 | ### Dependencies 37 | 38 | The second argument to `use_effect` is a list of dependencies. The function will only be called when the dependencies 39 | change and at least once when the program starts. 40 | 41 | ```py 42 | from hooks import use_effect 43 | 44 | 45 | def my_stateful_function(name: str) -> None: 46 | # Will only print "Hello, {name}" when the name changes 47 | use_effect(lambda: print(f"Hello, {name}"), [name]) 48 | return 49 | 50 | 51 | my_stateful_function("John") # Output: Hello, John 52 | my_stateful_function("John") # Output: 53 | my_stateful_function("Jane") # Output: Hello, Jane 54 | my_stateful_function("Jane") # Output: 55 | ``` 56 | 57 | As you can see in the example above, the function is only called when the name changes. This is akin to caching the 58 | result of the function using something like functool's `lrucache`. 59 | The main difference is that you may cache different actions inside the function instead of the entire function itself which is pretty neat. 60 | 61 | --- 62 | ### As a decorator 63 | 64 | The `use_effect` hook can also be used as a decorator. This is useful when your use_effect hook is longer than a single line. 65 | 66 | ```py 67 | from hooks import use_effect 68 | 69 | def my_stateful_function(name: str) -> None: 70 | 71 | @use_effect(dependencies=[name], decorating=True) 72 | def my_effect(): 73 | print(f"Hello, {name}") 74 | 75 | return 76 | 77 | my_stateful_function("John") # Output: Hello, John 78 | my_stateful_function("John") # Output: 79 | my_stateful_function("Jane") # Output: Hello, Jane 80 | my_stateful_function("Jane") # Output: 81 | ``` 82 | 83 | ### Next steps 84 | 85 | Learn the next basic hook: [use_context](use_context.md) 86 | -------------------------------------------------------------------------------- /src/hooks/plugins/redis_backend.py: -------------------------------------------------------------------------------- 1 | from typing import Any, Union 2 | 3 | from hooks.backends.async_interface import AsyncHooksBackend 4 | from hooks.backends.interface import HooksBackend 5 | 6 | try: 7 | # Extend pickle to support lambdas 8 | import dill as pickle 9 | import redis 10 | 11 | class RedisBackend(HooksBackend): 12 | redis_client = None 13 | 14 | @classmethod 15 | def use(cls, host: str, port: int, *args: Any, **kwargs: Any) -> Any: 16 | cls.redis_client = redis.Redis(host=host, port=port, **kwargs) 17 | super().use(**kwargs) 18 | return cls 19 | 20 | @classmethod 21 | def load(cls, identifier: str) -> Any: 22 | if cls.redis_client: 23 | return pickle.loads(cls.redis_client.get(identifier)) 24 | else: 25 | raise Exception("Redis client not initialized") 26 | 27 | @classmethod 28 | def save(cls, identifier: str, value: Any) -> Union[bool, None, Any]: 29 | if cls.redis_client: 30 | return cls.redis_client.set(identifier, pickle.dumps(value)) 31 | else: 32 | raise Exception("Redis client not initialized") 33 | 34 | @classmethod 35 | def exists(cls, identifier: str) -> bool: 36 | if cls.redis_client: 37 | return cls.redis_client.exists(identifier) == 1 38 | else: 39 | raise Exception("Redis client not initialized") 40 | 41 | @classmethod 42 | def reset_backend(cls): 43 | if cls.redis_client: 44 | for key in cls.redis_client.scan_iter("*"): 45 | cls.redis_client.delete(key) 46 | 47 | import redis.asyncio as async_redis 48 | 49 | class AsyncRedisBackend(AsyncHooksBackend): 50 | redis_client = None 51 | 52 | @classmethod 53 | async def use(cls, host: str, port: int, *args: Any, **kwargs: Any) -> Any: 54 | cls.redis_client = async_redis.Redis(host=host, port=port, **kwargs) 55 | await super().use(**kwargs) 56 | return cls 57 | 58 | @classmethod 59 | async def load(cls, identifier: str) -> Any: 60 | if cls.redis_client: 61 | return pickle.loads(await cls.redis_client.get(identifier)) 62 | else: 63 | raise Exception("Redis client not initialized") 64 | 65 | @classmethod 66 | async def save(cls, identifier: str, value: Any) -> Union[bool, None, Any]: 67 | if cls.redis_client: 68 | return await cls.redis_client.set(identifier, pickle.dumps(value)) 69 | else: 70 | raise Exception("Redis client not initialized") 71 | 72 | @classmethod 73 | async def exists(cls, identifier: str) -> bool: 74 | if cls.redis_client: 75 | return await cls.redis_client.exists(identifier) == 1 76 | else: 77 | raise Exception("Redis client not initialized") 78 | 79 | @classmethod 80 | async def reset_backend(cls): 81 | if cls.redis_client: 82 | for key in await cls.redis_client.scan_iter("*"): 83 | await cls.redis_client.delete(key) 84 | 85 | except ImportError: 86 | raise ImportError("Redis backend requires redis to be installed") 87 | -------------------------------------------------------------------------------- /tests/test_hooks/test_custom_hooks.py: -------------------------------------------------------------------------------- 1 | from hooks import use_state 2 | from hooks.asyncio.use import use_state as async_use_state 3 | 4 | 5 | def test_custom_counter_hook() -> None: 6 | def use_counter() -> int: 7 | counter, set_counter = use_state(0) 8 | set_counter(counter + 1) 9 | return counter 10 | 11 | class Foo: 12 | def local_state(self) -> int: 13 | return use_counter() 14 | 15 | def other_local_state(self) -> int: 16 | return use_counter() 17 | 18 | foo = Foo() 19 | 20 | assert foo.local_state() == 0 21 | assert foo.local_state() == 1 22 | assert foo.other_local_state() == 0 23 | assert foo.other_local_state() == 1 24 | assert Foo().local_state() == 0 25 | assert Foo().local_state() == 0 26 | assert Foo().other_local_state() == 0 27 | 28 | 29 | def test_custom_counter_hook_when_not_using_use_prefix() -> None: 30 | def wrongly_named_counter() -> int: 31 | counter, set_counter = use_state(0) 32 | set_counter(counter + 1) 33 | return counter 34 | 35 | class Foo: 36 | def local_state(self) -> int: 37 | return wrongly_named_counter() 38 | 39 | def other_local_state(self) -> int: 40 | return wrongly_named_counter() 41 | 42 | foo = Foo() 43 | 44 | assert foo.local_state() == 0 45 | assert foo.local_state() == 1 46 | assert foo.other_local_state() == 2 47 | assert foo.other_local_state() == 3 48 | assert Foo().local_state() == 4 49 | assert Foo().local_state() == 5 50 | assert Foo().other_local_state() == 6 51 | 52 | 53 | async def test_custom_counter_hook_async(async_backend) -> None: 54 | async def use_counter() -> int: 55 | counter, set_counter = await async_use_state(0) 56 | await set_counter(counter + 1) 57 | return counter 58 | 59 | class Foo: 60 | async def local_state(self) -> int: 61 | return await use_counter() 62 | 63 | async def other_local_state(self) -> int: 64 | return await use_counter() 65 | 66 | foo = Foo() 67 | 68 | assert await foo.local_state() == 0 69 | assert await foo.local_state() == 1 70 | assert await foo.other_local_state() == 0 71 | assert await foo.other_local_state() == 1 72 | assert await Foo().local_state() == 0 73 | assert await Foo().local_state() == 0 74 | assert await Foo().other_local_state() == 0 75 | 76 | 77 | async def test_custom_counter_hook_when_not_using_use_prefix_async( 78 | async_backend, 79 | ) -> None: 80 | async def wrongly_named_counter() -> int: 81 | counter, set_counter = await async_use_state(0) 82 | await set_counter(counter + 1) 83 | return counter 84 | 85 | class Foo: 86 | async def local_state(self) -> int: 87 | return await wrongly_named_counter() 88 | 89 | async def other_local_state(self) -> int: 90 | return await wrongly_named_counter() 91 | 92 | foo = Foo() 93 | 94 | assert await foo.local_state() == 0 95 | assert await foo.local_state() == 1 96 | assert await foo.other_local_state() == 2 97 | assert await foo.other_local_state() == 3 98 | assert await Foo().local_state() == 4 99 | assert await Foo().local_state() == 5 100 | assert await Foo().other_local_state() == 6 101 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Covenant Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | In the interest of fostering an open and welcoming environment, we as 6 | contributors and maintainers pledge to making participation in our project and 7 | our community a harassment-free experience for everyone, regardless of age, body 8 | size, disability, ethnicity, sex characteristics, gender identity and expression, 9 | level of experience, education, socio-economic status, nationality, personal 10 | appearance, race, religion, or sexual identity and orientation. 11 | 12 | ## Our Standards 13 | 14 | Examples of behavior that contributes to creating a positive environment 15 | include: 16 | 17 | * Using welcoming and inclusive language 18 | * Being respectful of differing viewpoints and experiences 19 | * Gracefully accepting constructive criticism 20 | * Focusing on what is best for the community 21 | * Showing empathy towards other community members 22 | 23 | Examples of unacceptable behavior by participants include: 24 | 25 | * The use of sexualized language or imagery and unwelcome sexual attention or 26 | advances 27 | * Trolling, insulting/derogatory comments, and personal or political attacks 28 | * Public or private harassment 29 | * Publishing others' private information, such as a physical or electronic 30 | address, without explicit permission 31 | * Other conduct which could reasonably be considered inappropriate in a 32 | professional setting 33 | 34 | ## Our Responsibilities 35 | 36 | Project maintainers are responsible for clarifying the standards of acceptable 37 | behavior and are expected to take appropriate and fair corrective action in 38 | response to any instances of unacceptable behavior. 39 | 40 | Project maintainers have the right and responsibility to remove, edit, or 41 | reject comments, commits, code, wiki edits, issues, and other contributions 42 | that are not aligned to this Code of Conduct, or to ban temporarily or 43 | permanently any contributor for other behaviors that they deem inappropriate, 44 | threatening, offensive, or harmful. 45 | 46 | ## Scope 47 | 48 | This Code of Conduct applies both within project spaces and in public spaces 49 | when an individual is representing the project or its community. Examples of 50 | representing a project or community include using an official project e-mail 51 | address, posting via an official social media account, or acting as an appointed 52 | representative at an online or offline event. Representation of a project may be 53 | further defined and clarified by project maintainers. 54 | 55 | ## Enforcement 56 | 57 | Instances of abusive, harassing, or otherwise unacceptable behavior may be 58 | reported by contacting the project team at amit.assaraf@gmail.com. All 59 | complaints will be reviewed and investigated and will result in a response that 60 | is deemed necessary and appropriate to the circumstances. The project team is 61 | obligated to maintain confidentiality with regard to the reporter of an incident. 62 | Further details of specific enforcement policies may be posted separately. 63 | 64 | Project maintainers who do not follow or enforce the Code of Conduct in good 65 | faith may face temporary or permanent repercussions as determined by other 66 | members of the project's leadership. 67 | 68 | ## Attribution 69 | 70 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, 71 | available at https://www.contributor-covenant.org/version/1/4/code-of-conduct.html 72 | 73 | [homepage]: https://www.contributor-covenant.org 74 | 75 | For answers to common questions about this code of conduct, see 76 | https://www.contributor-covenant.org/faq 77 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | #* Variables 2 | SHELL := /usr/bin/env bash 3 | PYTHON := python 4 | PYTHONPATH := `pwd` 5 | 6 | #* Docker variables 7 | IMAGE := python-hooks 8 | VERSION := latest 9 | 10 | #* Poetry 11 | .PHONY: poetry-download 12 | poetry-download: 13 | curl -sSL https://install.python-poetry.org | $(PYTHON) - 14 | 15 | .PHONY: poetry-remove 16 | poetry-remove: 17 | curl -sSL https://install.python-poetry.org | $(PYTHON) - --uninstall 18 | 19 | #* Installation 20 | .PHONY: install 21 | install: 22 | poetry lock -n && poetry export --without-hashes > requirements.txt 23 | poetry install -n 24 | -poetry run mypy --install-types --non-interactive ./src 25 | 26 | .PHONY: pre-commit-install 27 | pre-commit-install: 28 | poetry run pre-commit install 29 | 30 | #* Formatters 31 | .PHONY: codestyle 32 | codestyle: 33 | poetry run pyupgrade --exit-zero-even-if-changed --py39-plus **/*.py 34 | poetry run isort --settings-path pyproject.toml ./ 35 | poetry run black --config pyproject.toml ./ 36 | 37 | .PHONY: formatting 38 | formatting: codestyle 39 | 40 | #* Linting 41 | .PHONY: test 42 | test: 43 | PYTHONPATH=$(PYTHONPATH) poetry run pytest -c pyproject.toml --cov-report=html --cov=hooks tests/ 44 | poetry run coverage-badge -o assets/images/coverage.svg -f 45 | 46 | .PHONY: check-codestyle 47 | check-codestyle: 48 | poetry run isort --diff --check-only --settings-path pyproject.toml ./ 49 | poetry run black --diff --check --config pyproject.toml ./ 50 | poetry run darglint --verbosity 2 hooks tests 51 | 52 | .PHONY: mypy 53 | mypy: 54 | poetry run mypy --config-file pyproject.toml ./src 55 | 56 | .PHONY: check-safety 57 | check-safety: 58 | poetry check 59 | poetry run safety check --full-report 60 | poetry run bandit -ll --recursive hooks tests 61 | 62 | .PHONY: lint 63 | lint: test check-codestyle mypy check-safety 64 | 65 | .PHONY: update-dev-deps 66 | update-dev-deps: 67 | poetry add -D bandit@latest darglint@latest "isort[colors]@latest" mypy@latest pre-commit@latest pydocstyle@latest pylint@latest pytest@latest pyupgrade@latest safety@latest coverage@latest coverage-badge@latest pytest-html@latest pytest-cov@latest 68 | poetry add -D --allow-prereleases black@latest 69 | 70 | #* Docker 71 | # Example: make docker-build VERSION=latest 72 | # Example: make docker-build IMAGE=some_name VERSION=0.1.0 73 | .PHONY: docker-build 74 | docker-build: 75 | @echo Building docker $(IMAGE):$(VERSION) ... 76 | docker build \ 77 | -t $(IMAGE):$(VERSION) . \ 78 | -f ./docker/Dockerfile --no-cache 79 | 80 | # Example: make docker-remove VERSION=latest 81 | # Example: make docker-remove IMAGE=some_name VERSION=0.1.0 82 | .PHONY: docker-remove 83 | docker-remove: 84 | @echo Removing docker $(IMAGE):$(VERSION) ... 85 | docker rmi -f $(IMAGE):$(VERSION) 86 | 87 | #* Cleaning 88 | .PHONY: pycache-remove 89 | pycache-remove: 90 | find . | grep -E "(__pycache__|\.pyc|\.pyo$$)" | xargs rm -rf 91 | 92 | .PHONY: dsstore-remove 93 | dsstore-remove: 94 | find . | grep -E ".DS_Store" | xargs rm -rf 95 | 96 | .PHONY: mypycache-remove 97 | mypycache-remove: 98 | find . | grep -E ".mypy_cache" | xargs rm -rf 99 | 100 | .PHONY: ipynbcheckpoints-remove 101 | ipynbcheckpoints-remove: 102 | find . | grep -E ".ipynb_checkpoints" | xargs rm -rf 103 | 104 | .PHONY: pytestcache-remove 105 | pytestcache-remove: 106 | find . | grep -E ".pytest_cache" | xargs rm -rf 107 | 108 | .PHONY: build-remove 109 | build-remove: 110 | rm -rf build/ 111 | 112 | .PHONY: cleanup 113 | cleanup: pycache-remove dsstore-remove mypycache-remove ipynbcheckpoints-remove pytestcache-remove 114 | -------------------------------------------------------------------------------- /src/hooks/scope.py: -------------------------------------------------------------------------------- 1 | # mypy: ignore-errors 2 | from typing import Any, Callable, Optional 3 | 4 | import inspect 5 | from contextlib import contextmanager 6 | from functools import wraps 7 | 8 | HOOKED_FUNCTION_ATTRIBUTE = "__hooked_function__" 9 | 10 | 11 | @contextmanager 12 | def _hook_scope_manager( 13 | wrapped: Callable[[Any], Any], 14 | parametrize: Optional[list[str]] = None, 15 | *args: Any, 16 | **kwargs: dict[str, Any], 17 | ) -> None: 18 | """ 19 | A context manager to manage the scope of hooks. This is used to identify the scope of hooks and to limit the scope 20 | of hooks to the function and keys. 21 | :param wrapped: The function that is being wrapped 22 | :param parametrize: The keys to limit the scope to 23 | :param args: The args of the function 24 | :param kwargs: The kwargs of the function 25 | """ 26 | 27 | # Code to acquire resource, e.g.: 28 | identifier = "" 29 | 30 | # We get the names of the arguments as sometimes they are not passed as kwargs and we need to identify 31 | # them in order to see if they are in the parametrize. 32 | args_names = wrapped.__code__.co_varnames[: wrapped.__code__.co_argcount] 33 | 34 | for key, value in [*kwargs.items(), *list(zip(args_names, args))]: 35 | if parametrize is None or key in parametrize: 36 | identifier += f"{key}:{value};" 37 | _hook_scope_manager.current_identifier.append(f"{wrapped.__qualname__}{identifier}") 38 | try: 39 | yield 40 | finally: 41 | # Code to release resource, e.g.: 42 | _hook_scope_manager.current_identifier.pop() 43 | 44 | 45 | _hook_scope_manager.current_identifier = [] 46 | 47 | 48 | def hook_scope( 49 | parametrize: Optional[list[str]] = None, use_global_scope: Optional[bool] = False 50 | ) -> Callable[[Any], Any]: 51 | """ 52 | Create a scope for all hooks in the scope. The scope will be added to the hook identifiers to allow for state to 53 | be scoped either per the function and below or by the function and keys. 54 | :param parametrize: The keys to limit the scope to. 55 | :param use_global_scope: If True, the scope and all hooks will be persisted globally and will not be limited to the 56 | instance. If this is True, you must specify the keys to limit the scope to because we cannot identify the self / cls 57 | argument automatically. 58 | """ 59 | 60 | if use_global_scope and parametrize is None: 61 | raise ValueError( 62 | "You must specify the keys to limit the state to (parametrize) if you want to use global " 63 | "scope, if your function only has self or cls as arguments, you can use an empty list '[]'." 64 | ) 65 | 66 | # The function argument is called "__hooked_function" on purpose to be able to identify it in the frame utils 67 | def scope_decorator(__hooked_function__) -> Callable[[Any], Any]: 68 | __hooked_function__.use_global_scope = use_global_scope 69 | 70 | if inspect.iscoroutinefunction(__hooked_function__): 71 | 72 | @wraps(__hooked_function__) 73 | async def wrapper(*args, **kwargs) -> Any: 74 | with _hook_scope_manager( 75 | __hooked_function__, parametrize, *args, **kwargs 76 | ): 77 | return await __hooked_function__(*args, **kwargs) 78 | 79 | else: 80 | 81 | @wraps(__hooked_function__) 82 | def wrapper(*args, **kwargs) -> Any: 83 | with _hook_scope_manager( 84 | __hooked_function__, parametrize, *args, **kwargs 85 | ): 86 | return __hooked_function__(*args, **kwargs) 87 | 88 | return wrapper 89 | 90 | return scope_decorator 91 | -------------------------------------------------------------------------------- /src/hooks/use.py: -------------------------------------------------------------------------------- 1 | # mypy: ignore-errors 2 | 3 | from typing import Any, Callable, Optional, TypeVar, Union 4 | 5 | from .backends.backend_state import get_hooks_backend 6 | from .frame_utils import __identify_hook_and_backend 7 | 8 | T = TypeVar("T") 9 | 10 | 11 | def use_state(default_value: T) -> tuple[T, Callable[[Any], Any]]: 12 | """ 13 | Create a stateful hook. 14 | :param default_value: The default value of the state 15 | :return: The current value of the state and a function to update the state 16 | """ 17 | identifier, _backend = __identify_hook_and_backend() 18 | 19 | def state_wrapper(value: T) -> None: 20 | state_wrapper.val = value 21 | _backend.save(identifier, value) 22 | 23 | if _backend.exists(identifier): 24 | loaded_value = _backend.load(identifier) 25 | state_wrapper.val = loaded_value 26 | return state_wrapper.val, state_wrapper 27 | 28 | state_wrapper(default_value) 29 | return state_wrapper.val, state_wrapper 30 | 31 | 32 | def use_effect( 33 | callback: Optional[Callable[[], Any]] = None, 34 | dependencies: Optional[list[Any]] = None, 35 | decorating: Optional[bool] = False, 36 | ) -> Union[Callable[[Callable[[], Any]], Callable[[], None]], None]: 37 | """ 38 | Create an effect hook. The callback will be called when the dependencies change. 39 | :param callback: The callback to call when the dependencies change 40 | :param dependencies: The dependencies to watch for changes 41 | :param decorating: Whether the use_effect hook is currently used as a decorator 42 | :return: None 43 | """ 44 | if decorating: 45 | 46 | def decorator(decorated_callback: Callable[[], Any]) -> Callable[[], None]: 47 | use_effect(decorated_callback, dependencies) 48 | return decorated_callback 49 | 50 | return decorator 51 | 52 | saved_dependencies, set_dependencies = use_state(dependencies or []) 53 | has_ran, set_initial_ran = use_state(False) 54 | if saved_dependencies != dependencies or not has_ran: 55 | set_dependencies(dependencies) 56 | set_initial_ran(True) 57 | callback() 58 | return 59 | 60 | 61 | def use_memo(callback: Callable[[], Any], dependencies: list[Any]) -> Any: 62 | """ 63 | Create a memoized hook. The callback will be called when the dependencies change. Practically it is an alias for 64 | use_effect. 65 | :param callback: The callback to call when the dependencies change 66 | :param dependencies: The dependencies to watch for changes 67 | :return: The return value of the callback 68 | """ 69 | return use_effect(callback, dependencies) 70 | 71 | 72 | def create_context(default_value: Any) -> str: 73 | """ 74 | Create a context hook. 75 | :param default_value: The default value of the context 76 | :return: The identifier of the context 77 | """ 78 | identifier, _backend = __identify_hook_and_backend( 79 | always_global_backend=True, prefix="__hooks_context__" 80 | ) 81 | if not _backend.exists(identifier): 82 | _backend.save(identifier, default_value) 83 | return identifier 84 | 85 | 86 | def set_context_value(context: Any, value: Any) -> str: 87 | """ 88 | Set the value of a context hook. The value will be saved in the backend. 89 | :param context: The identifier of the context 90 | :param value: The value to set 91 | :return: The value that was set 92 | """ 93 | _backend = get_hooks_backend() 94 | _backend.save(context, value) 95 | return value 96 | 97 | 98 | def use_context(context: Any) -> Any: 99 | """ 100 | Create a context hook. The value of the context will be loaded from the backend. 101 | :param context: The identifier of the context 102 | :return: The value of the context 103 | """ 104 | _backend = get_hooks_backend() 105 | return _backend.load(context) 106 | -------------------------------------------------------------------------------- /src/hooks/asyncio/use.py: -------------------------------------------------------------------------------- 1 | # mypy: ignore-errors 2 | from typing import Any, Callable, Optional, TypeVar 3 | 4 | import inspect 5 | from collections.abc import Coroutine 6 | 7 | from ..backends.backend_state import get_hooks_backend 8 | from ..frame_utils import __identify_hook_and_backend 9 | 10 | T = TypeVar("T") 11 | 12 | 13 | async def use_state(default_value: T) -> tuple[T, Callable[[Any], Any]]: 14 | """ 15 | Create a stateful hook. 16 | :param default_value: The default value of the state 17 | :return: The current value of the state and a function to update the state 18 | """ 19 | identifier, _backend = __identify_hook_and_backend(using_async=True) 20 | 21 | async def state_wrapper(value: T) -> None: 22 | state_wrapper.val = value 23 | await _backend.save(identifier, value) 24 | 25 | exists = await _backend.exists(identifier) 26 | if exists is True: 27 | loaded_value = await _backend.load(identifier) 28 | state_wrapper.val = loaded_value 29 | return state_wrapper.val, state_wrapper 30 | 31 | await state_wrapper(default_value) 32 | return state_wrapper.val, state_wrapper 33 | 34 | 35 | async def use_effect( 36 | callback: Optional[Callable[[], Any]] = None, 37 | dependencies: Optional[list[Any]] = None, 38 | ) -> Optional[Callable[[Callable[[], Any]], Coroutine[Any, Any, Callable[[], None]]]]: 39 | """ 40 | Create an effect hook. The callback will be called when the dependencies change. 41 | :param callback: The callback to call when the dependencies change 42 | :param dependencies: The dependencies to watch for changes 43 | :param decorating: Whether the use_effect hook is currently used as a decorator 44 | :return: None 45 | """ 46 | saved_dependencies, set_dependencies = await use_state(dependencies or []) 47 | has_ran, set_initial_ran = await use_state(False) 48 | if saved_dependencies != dependencies or not has_ran: 49 | await set_dependencies(dependencies) 50 | await set_initial_ran(True) 51 | if inspect.iscoroutinefunction(callback): 52 | await callback() 53 | else: 54 | callback() 55 | return 56 | 57 | 58 | async def use_memo(callback: Callable[[], Any], dependencies: list[Any]) -> Any: 59 | """ 60 | Create a memoized hook. The callback will be called when the dependencies change. Practically it is an alias for 61 | use_effect. 62 | :param callback: The callback to call when the dependencies change 63 | :param dependencies: The dependencies to watch for changes 64 | :return: The return value of the callback 65 | """ 66 | return use_effect(callback, dependencies) 67 | 68 | 69 | async def create_context(default_value: Any) -> str: 70 | """ 71 | Create a context hook. 72 | :param default_value: The default value of the context 73 | :return: The identifier of the context 74 | """ 75 | identifier, _backend = __identify_hook_and_backend( 76 | always_global_backend=True, prefix="__hooks_context__", using_async=True 77 | ) 78 | if (await _backend.exists(identifier)) is False: 79 | await _backend.save(identifier, default_value) 80 | return identifier 81 | 82 | 83 | async def set_context_value(context: Any, value: Any) -> str: 84 | """ 85 | Set the value of a context hook. The value will be saved in the backend. 86 | :param context: The identifier of the context 87 | :param value: The value to set 88 | :return: The value that was set 89 | """ 90 | _backend = get_hooks_backend(using_async=True) 91 | await _backend.save(context, value) 92 | return value 93 | 94 | 95 | async def use_context(context: Any) -> Any: 96 | """ 97 | Create a context hook. The value of the context will be loaded from the backend. 98 | :param context: The identifier of the context 99 | :return: The value of the context 100 | """ 101 | _backend = get_hooks_backend(using_async=True) 102 | return await _backend.load(context) 103 | -------------------------------------------------------------------------------- /docs/scoping/default_scoping.md: -------------------------------------------------------------------------------- 1 | By default, hooks are scoped to the smallest enclosing scope. This means that if you call a hook inside a function, the 2 | hook will only be scoped to that function. If you call a hook inside a class, the hook will only be scoped to that 3 | class. If you call a hook inside a module, the hook will only be scoped to that module. 4 | 5 | Hooks scoping mechanism attempts to work in a very predictable way in order to avoid unexpected behavior. 6 | 7 | Here is an example of how hooks are scoped to the smallest enclosing scope: 8 | 9 | ```python 10 | from hooks import use_state 11 | 12 | 13 | def get_tasks(): 14 | state, set_state = use_state({"tasks": []}) 15 | return state["tasks"] 16 | 17 | 18 | print(get_tasks()) # Output: [] 19 | 20 | 21 | def add_task(task: str): 22 | state, set_state = use_state() 23 | set_state({"tasks": state["tasks"] + [task]}) 24 | 25 | 26 | add_task("Do the dishes") 27 | add_task("Do the laundry") 28 | print(get_tasks()) # Output: [] 29 | ``` 30 | 31 | As you can see in the example above, the `get_tasks` function is scoped to the `get_tasks` function, and the `add_task` 32 | function is scoped to the `add_task` function. This means that the `get_tasks` function will always return an empty 33 | list, 34 | and the `add_task` function will always add a task to an empty list. 35 | 36 | --- 37 | ### Class scopes 38 | 39 | When using hooks inside a class, the scope by default will be limited to that class according to the type of method 40 | that the hook is called in. For example, if you call a hook inside a `classmethod`, the hook will be scoped to the class 41 | and not the instance. If you call a hook inside an `instancemethod`, the hook will be scoped to the instance and not the 42 | class. If you call a hook inside a `staticmethod`, the hook will be scoped globally. 43 | 44 | ```python 45 | from hooks import use_state 46 | 47 | 48 | class CounterClass: 49 | def instance_counter(self): 50 | count, set_count = use_state(0) 51 | set_count(count + 1) 52 | return count 53 | 54 | @classmethod 55 | def class_counter(cls): 56 | count, set_count = use_state(0) 57 | set_count(count + 1) 58 | return count 59 | 60 | @staticmethod 61 | def static_counter(): 62 | count, set_count = use_state(0) 63 | set_count(count + 1) 64 | return count 65 | 66 | 67 | counter = CounterClass() 68 | print(counter.instance_counter()) # Output: 0 69 | print(counter.instance_counter()) # Output: 1 70 | print(CounterClass().instance_counter()) # Output: 0 71 | print(CounterClass().instance_counter()) # Output: 0 72 | print(CounterClass.class_counter()) # Output: 0 73 | print(CounterClass.class_counter()) # Output: 1 74 | print(CounterClass.static_counter()) # Output: 0 75 | ``` 76 | 77 | As you can see the default scoping mechanism for hooks inside a class is to scope the hook to the smallest enclosing 78 | scope and is predictable. However, you may want to change the default scoping mechanism for hooks inside a class. You 79 | can do this by using the [hooks_scope](scope_decorator.md) decorator. 80 | 81 | --- 82 | 83 | ### Scopes and function parameters 84 | 85 | When using hooks inside a function, the scope by default will ignore function parameters. This means that if you call a 86 | hook inside a function, the hook will be scoped to the function and not the function parameters (Unless when using 87 | parameters as dependencies on [use_effect](../hooks/base_hooks/use_effect.md)). 88 | 89 | ```python 90 | from hooks import use_state 91 | 92 | 93 | def get_count(other_param: str): 94 | count, set_count = use_state(0) 95 | set_count(count + 1) 96 | print(other_param) 97 | return count 98 | 99 | 100 | print(get_count("Hello")) # Output: 0 101 | print(get_count("World")) # Output: 1 102 | print(get_count("This wont affect the state")) # Output: 2 103 | ``` 104 | 105 | --- 106 | ### Next steps 107 | 108 | Learn about scoping hooks with [hooks_scope](../scoping/scope_decorator.md) decorator. 109 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | # Poetry pyproject.toml: https://python-poetry.org/docs/pyproject/ 2 | [build-system] 3 | requires = ["setuptools"] 4 | build-backend = "setuptools.build_meta" 5 | 6 | [tool.poetry] 7 | name = "python-hooks" 8 | version = "0.2.1" 9 | description = "A React inspired way to code in Python" 10 | readme = "README.md" 11 | authors = ["Amit Assaraf "] 12 | license = "MIT" 13 | repository = "https://github.com/amitassaraf/python-hooks" 14 | homepage = "https://github.com/amitassaraf/python-hooks" 15 | packages = [{include = "hooks", from = "src"}] 16 | 17 | # Keywords description https://python-poetry.org/docs/pyproject/#keywords 18 | keywords = [] #! Update me 19 | 20 | # Pypi classifiers: https://pypi.org/classifiers/ 21 | classifiers = [ #! Update me 22 | "Development Status :: 3 - Alpha", 23 | "Intended Audience :: Developers", 24 | "Operating System :: OS Independent", 25 | "Topic :: Software Development :: Libraries :: Python Modules", 26 | "License :: OSI Approved :: MIT License", 27 | "Programming Language :: Python :: 3", 28 | "Programming Language :: Python :: 3.9", 29 | ] 30 | 31 | [tool.poetry.dependencies] 32 | python = "^3.9" 33 | python-box = {version = "^7.0.1", extras = ["all"]} 34 | dill = "^0.3.7" 35 | pyhashxx = "^0.1.3" 36 | pytest-asyncio = "^0.21.1" 37 | 38 | [tool.poetry.dev-dependencies] 39 | bandit = "^1.7.1" 40 | black = {version = "^22.3.0", allow-prereleases = true} 41 | darglint = "^1.8.1" 42 | isort = {extras = ["colors"], version = "^5.10.1"} 43 | mypy = "^0.910" 44 | mypy-extensions = "^0.4.3" 45 | pre-commit = "^3.3.3" 46 | pydocstyle = "^6.1.1" 47 | pylint = "^2.11.1" 48 | pytest = "^7.4.0" 49 | pyupgrade = "^2.29.1" 50 | safety = "^2.2.0" 51 | coverage = "^6.1.2" 52 | coverage-badge = "^1.1.0" 53 | pytest-html = "^3.1.1" 54 | pytest-cov = "^3.0.0" 55 | mkdocs-material = "^9.1.21" 56 | anyio = "^3.7.1" 57 | 58 | [tool.black] 59 | # https://github.com/psf/black 60 | target-version = ["py39"] 61 | line-length = 88 62 | color = true 63 | 64 | exclude = ''' 65 | /( 66 | \.git 67 | | \.hg 68 | | \.mypy_cache 69 | | \.tox 70 | | \.venv 71 | | _build 72 | | buck-out 73 | | build 74 | | dist 75 | | env 76 | | venv 77 | )/ 78 | ''' 79 | 80 | [tool.isort] 81 | # https://github.com/timothycrosley/isort/ 82 | py_version = 39 83 | line_length = 88 84 | 85 | known_typing = ["typing", "types", "typing_extensions", "mypy", "mypy_extensions"] 86 | sections = ["FUTURE", "TYPING", "STDLIB", "THIRDPARTY", "FIRSTPARTY", "LOCALFOLDER"] 87 | include_trailing_comma = true 88 | profile = "black" 89 | multi_line_output = 3 90 | indent = 4 91 | color_output = true 92 | 93 | [tool.mypy] 94 | # https://mypy.readthedocs.io/en/latest/config_file.html#using-a-pyproject-toml-file 95 | python_version = 3.9 96 | pretty = true 97 | show_traceback = true 98 | color_output = true 99 | 100 | allow_redefinition = false 101 | check_untyped_defs = true 102 | disallow_any_generics = true 103 | disallow_incomplete_defs = true 104 | ignore_missing_imports = true 105 | implicit_reexport = false 106 | no_implicit_optional = true 107 | show_column_numbers = true 108 | show_error_codes = true 109 | show_error_context = true 110 | strict_equality = true 111 | strict_optional = true 112 | warn_no_return = true 113 | warn_redundant_casts = true 114 | warn_return_any = false 115 | warn_unreachable = true 116 | warn_unused_configs = true 117 | warn_unused_ignores = true 118 | 119 | [tool.pytest.ini_options] 120 | # https://docs.pytest.org/en/6.2.x/customize.html#pyproject-toml 121 | # Directories that are not visited by pytest collector: 122 | norecursedirs =["hooks", "*.egg", ".eggs", "dist", "build", "docs", ".tox", ".git", "__pycache__"] 123 | doctest_optionflags = ["NUMBER", "NORMALIZE_WHITESPACE", "IGNORE_EXCEPTION_DETAIL"] 124 | 125 | # Extra options: 126 | addopts = [ 127 | "--strict-markers", 128 | "--tb=short", 129 | "--doctest-modules", 130 | "--doctest-continue-on-failure", 131 | ] 132 | 133 | [tool.coverage.run] 134 | source = ["tests"] 135 | 136 | [coverage.paths] 137 | source = "hooks" 138 | 139 | [coverage.run] 140 | branch = true 141 | 142 | [coverage.report] 143 | fail_under = 50 144 | show_missing = true 145 | 146 | [tool.setuptools.package-dir] 147 | hooks = "src/hooks" 148 | -------------------------------------------------------------------------------- /docs/hooks/base_hooks/use_state.md: -------------------------------------------------------------------------------- 1 | The `use_state` hook is the most basic hook in the library. It allows you to store state in a function. It's similar to the `useState` hook in React. 2 | All other hooks are built on top of this hook. 3 | 4 | --- 5 | 6 | ### Basic use 7 | 8 | Let learn about `use_state`: 9 | ```py 10 | count, set_count = use_state(0) 11 | ``` 12 | 13 | `use_state` will always return a tuple of the current state and a function to update the state. The state can be any type, it can be a string, a number, a list or a dictionary. 14 | 15 | The state will automatically be persisted between function calls. See [Default Scoping](../../scoping/default.md) for more information on how the state is persisted. 16 | 17 | Let's see an example: 18 | 19 | ```py 20 | # We import the use_state hook from the hooks module 21 | from hooks import use_state 22 | 23 | def my_stateful_function() -> int: 24 | # We call the use_state hook and store the result in a variable called count and set_count 25 | count, set_count = use_state(0) 26 | # We mutate the state by adding 1 to the current count 27 | set_count(count + 1) 28 | return count 29 | 30 | print(my_stateful_function()) # Output: 0 31 | print(my_stateful_function()) # Output: 1 32 | print(my_stateful_function()) # Output: 2 33 | ``` 34 | 35 | As you can see, the state is persisted between function calls. This is because the state is stored in the function's scope. 36 | By default, the state is persisted only for the duration of the program. If you run the program again, the state will be reset. 37 | State may be persisted for longer periods of time by using a different [backend](../../backends/default.md). 38 | --- 39 | ### Multiple state hooks 40 | 41 | Additionally, we can use multiple `use_state` hooks in a single function. This allows us to store multiple pieces of state in a single function. 42 | 43 | ```py 44 | from hooks import use_state 45 | 46 | def my_stateful_function() -> tuple[int, str]: 47 | count, set_count = use_state(0) 48 | name, set_name = use_state("John") 49 | set_count(count + 1) 50 | set_name("Jane") 51 | return count, name 52 | 53 | print(my_stateful_function()) # Output: (0, "John") 54 | print(my_stateful_function()) # Output: (1, "Jane") 55 | print(my_stateful_function()) # Output: (2, "Jane") 56 | ``` 57 | 58 | --- 59 | 60 | ### State hooks inside objects 61 | 62 | We can also use `use_state` hooks inside objects. This allows us to store state which is persisted for the lifetime of the object. 63 | 64 | ```py 65 | from hooks import use_state 66 | 67 | class MyObject: 68 | def object_persisted_count(self): 69 | count, set_count = use_state(0) 70 | set_count(count + 1) 71 | return count 72 | 73 | obj = MyObject() 74 | print(obj.object_persisted_count()) # Output: 0 75 | print(obj.object_persisted_count()) # Output: 1 76 | 77 | my_other_obj = MyObject() 78 | print(my_other_obj.object_persisted_count()) # Output: 0 79 | ``` 80 | 81 | --- 82 | ### Class methods and static methods 83 | 84 | The `use_state` hook can also be used inside class methods and static methods. This allows us to store state which is persisted for the lifetime of the class. 85 | 86 | ```py 87 | from hooks import use_state 88 | 89 | class MyClass: 90 | @classmethod 91 | def class_persisted_count(cls): 92 | count, set_count = use_state(0) 93 | set_count(count + 1) 94 | return count 95 | 96 | @staticmethod 97 | def static_persisted_count(): 98 | count, set_count = use_state(0) 99 | set_count(count + 1) 100 | return count 101 | 102 | print(MyClass.class_persisted_count()) # Output: 0 103 | print(MyClass.class_persisted_count()) # Output: 1 104 | 105 | print(MyClass.static_persisted_count()) # Output: 0 106 | print(MyClass.static_persisted_count()) # Output: 1 107 | ``` 108 | 109 | __Note:__ the inner workings of hooks are not dependent on the naming of the arguments, meaning you do not have to name 110 | the instance or class arguments `self` and `cls` for the hooks to work. 111 | You can name them whatever you want. 112 | --- 113 | ### Thread safety of state hooks 114 | 115 | 116 | 117 | The `use_state` hook is currently not threadsafe. The reason being that the default backend is not threadsafe in order 118 | to maximize compatibility with different frameworks such as Flask. If you need to use `use_state` in a 119 | multithreaded environment you should use the `threadsafe` backend. See [Backends](../../backends/default.md) for more information. 120 | 121 | --- 122 | ### Next steps 123 | 124 | Learn the next basic hook: [use_effect](use_effect.md) 125 | -------------------------------------------------------------------------------- /src/hooks/reducers.py: -------------------------------------------------------------------------------- 1 | from typing import Any, Callable, Optional, Union 2 | 3 | from functools import wraps 4 | 5 | from .backends.backend_state import get_hooks_backend 6 | 7 | 8 | def __dispatch_factory( 9 | reducer: Callable[[dict[str, Any], dict[str, Any]], dict[str, Any]], 10 | state_fetcher: Callable[[], dict[str, Any]], 11 | set_state: Callable[[dict[str, Any]], Any], 12 | middleware: Union[ 13 | list[ 14 | Callable[ 15 | [dict[str, Any], Callable[[Any], Any], dict[str, Any]], dict[str, Any] 16 | ] 17 | ], 18 | None, 19 | ] = None, 20 | ) -> Callable[[dict[str, Any]], dict[str, Any]]: 21 | """ 22 | Create a dispatch function for a reducer. 23 | :param reducer: The reducer to use 24 | :param state_fetcher: The state_fetcher to use 25 | :param set_state: The set_state function to use 26 | :param middleware: The middleware to use 27 | :return: The dispatch function 28 | """ 29 | if middleware is None: 30 | middleware = [] 31 | 32 | def dispatch(action: dict[str, Any]) -> dict[str, Any]: 33 | """ 34 | Dispatch an action to the reducer. The action will be passed to the middleware. The middleware will be called 35 | in order. The last middleware will call the reducer. The reducer will return the new state. The new state will 36 | be set. 37 | :param action: The action to dispatch 38 | :return: The new state 39 | """ 40 | inner_middleware = middleware 41 | 42 | new_state: dict[str, Any] = state_fetcher() 43 | 44 | if inner_middleware is None: 45 | inner_middleware = [] 46 | inner_middleware.append( 47 | lambda inner_state, _, inner_action: reducer(inner_state, inner_action) 48 | ) 49 | 50 | def runner(state, middlewares, action): 51 | current = middlewares.pop(0) 52 | return current( 53 | state, 54 | lambda inner_state, inner_action: runner( 55 | inner_state, middlewares, inner_action 56 | ), 57 | action, 58 | ) 59 | 60 | state_change: dict[str, Any] = runner(new_state, inner_middleware, action) 61 | new_state = {**new_state, **state_change} 62 | set_state(new_state) 63 | return new_state 64 | 65 | return dispatch 66 | 67 | 68 | def use_reducer( 69 | reducer: Callable[[dict[str, Any], dict[str, Any]], dict[str, Any]], 70 | initial_state: Optional[dict[str, Any]] = None, 71 | middleware: Union[ 72 | list[ 73 | Callable[ 74 | [dict[str, Any], Callable[[Any], Any], dict[str, Any]], dict[str, Any] 75 | ] 76 | ], 77 | None, 78 | ] = None, 79 | ) -> tuple[dict[str, Any], Callable[[dict[str, Any]], dict[str, Any]]]: 80 | """ 81 | Create a reducer hook. The reducer will be called when the dispatch function is called. 82 | :param reducer: The reducer to use 83 | :param initial_state: The initial state to use 84 | :param middleware: The middlewares to use in order, if any 85 | :return: The state and the dispatch function 86 | """ 87 | _backend = get_hooks_backend() 88 | identifier = reducer.__module__ + (reducer.__qualname__ or reducer.__name__) 89 | 90 | def state_wrapper(value) -> None: 91 | state_wrapper.val = value 92 | _backend.save(identifier, value) 93 | 94 | def state_fetcher() -> dict[str, Any]: 95 | return state_wrapper.val 96 | 97 | if _backend.exists(identifier): 98 | loaded_value = _backend.load(identifier) 99 | state_wrapper.val = loaded_value 100 | return state_wrapper.val, __dispatch_factory( 101 | reducer, state_fetcher, state_wrapper, middleware or [] 102 | ) 103 | 104 | state_wrapper(initial_state) 105 | return state_wrapper.val, __dispatch_factory( 106 | reducer, state_fetcher, state_wrapper, middleware or [] 107 | ) 108 | 109 | 110 | def combine_reducers( 111 | *reducers: Callable[[dict[str, Any], dict[str, Any]], dict[str, Any]] 112 | ): 113 | """ 114 | Combine multiple reducers into one reducer. 115 | :param reducers: The reducers to combine 116 | :return: The combined reducer 117 | """ 118 | 119 | @wraps(reducers[0]) 120 | def combined_reducer( 121 | state: dict[str, Any], action: dict[str, Any] 122 | ) -> dict[str, Any]: 123 | """ 124 | Combine multiple reducers into one reducer. 125 | :param state: The state to use 126 | :param action: The action to use 127 | :return: The new state 128 | """ 129 | new_state = state 130 | for reducer in reducers: 131 | new_state = reducer(new_state, action) 132 | return new_state 133 | 134 | return combined_reducer 135 | -------------------------------------------------------------------------------- /tests/test_hooks/test_use_state.py: -------------------------------------------------------------------------------- 1 | from hooks.asyncio.use import use_state as async_use_state 2 | from hooks.use import use_state 3 | 4 | 5 | def test_local_state() -> None: 6 | class Foo: 7 | def local_state(self) -> int: 8 | counter, set_counter = use_state(0) 9 | set_counter(counter + 1) 10 | return counter 11 | 12 | foo = Foo() 13 | 14 | assert foo.local_state() == 0 15 | assert foo.local_state() == 1 16 | assert Foo().local_state() == 0 17 | assert Foo().local_state() == 0 18 | 19 | 20 | def test_local_state_with_self_renamed() -> None: 21 | class Foo: 22 | def local_state(not_self_to_trick_you) -> int: 23 | counter, set_counter = use_state(0) 24 | set_counter(counter + 1) 25 | return counter 26 | 27 | foo = Foo() 28 | 29 | assert foo.local_state() == 0 30 | assert foo.local_state() == 1 31 | assert Foo().local_state() == 0 32 | assert Foo().local_state() == 0 33 | 34 | 35 | def test_global_state() -> None: 36 | class Foo: 37 | @staticmethod 38 | def global_state() -> int: 39 | counter, set_counter = use_state(0) 40 | set_counter(counter + 1) 41 | return counter 42 | 43 | foo = Foo() 44 | 45 | assert Foo.global_state() == 0 46 | assert foo.global_state() == 1 47 | assert Foo().global_state() == 2 48 | 49 | def global_state() -> int: 50 | counter, set_counter = use_state(0) 51 | set_counter(counter + 1) 52 | return counter 53 | 54 | assert global_state() == 0 55 | assert global_state() == 1 56 | 57 | 58 | def test_class_state() -> None: 59 | class Bar: 60 | @classmethod 61 | def class_state(cls) -> int: 62 | counter, set_counter = use_state(0) 63 | set_counter(counter + 1) 64 | return counter 65 | 66 | bar = Bar() 67 | assert Bar.class_state() == 0 68 | assert Bar.class_state() == 1 69 | assert bar.class_state() == 2 70 | 71 | 72 | async def test_local_state_async(async_backend) -> None: 73 | class Foo: 74 | async def local_state(self) -> int: 75 | counter, set_counter = await async_use_state(0) 76 | await set_counter(counter + 1) 77 | return counter 78 | 79 | foo = Foo() 80 | 81 | assert await foo.local_state() == 0 82 | assert await foo.local_state() == 1 83 | assert await Foo().local_state() == 0 84 | assert await Foo().local_state() == 0 85 | 86 | 87 | async def test_local_two_state_async(async_backend) -> None: 88 | class Foo: 89 | async def local_state(self) -> tuple[int, int]: 90 | counter, set_counter = await async_use_state(0) 91 | await set_counter(counter + 1) 92 | other_counter, set_other_counter = await async_use_state(0) 93 | return counter, other_counter 94 | 95 | foo = Foo() 96 | 97 | assert await foo.local_state() == (0, 0) 98 | assert await foo.local_state() == (1, 0) 99 | assert await Foo().local_state() == (0, 0) 100 | assert await Foo().local_state() == (0, 0) 101 | 102 | 103 | async def test_local_state_with_self_renamed_async(async_backend) -> None: 104 | class Foo: 105 | async def local_state(not_self_to_trick_you) -> int: 106 | counter, set_counter = await async_use_state(0) 107 | await set_counter(counter + 1) 108 | return counter 109 | 110 | foo = Foo() 111 | 112 | assert await foo.local_state() == 0 113 | assert await foo.local_state() == 1 114 | assert await Foo().local_state() == 0 115 | assert await Foo().local_state() == 0 116 | 117 | 118 | async def test_global_state_async(async_backend) -> None: 119 | class Foo: 120 | @staticmethod 121 | async def global_state() -> int: 122 | counter, set_counter = await async_use_state(0) 123 | await set_counter(counter + 1) 124 | return counter 125 | 126 | foo = Foo() 127 | 128 | assert await Foo.global_state() == 0 129 | assert await foo.global_state() == 1 130 | assert await Foo().global_state() == 2 131 | 132 | async def global_state() -> int: 133 | counter, set_counter = await async_use_state(0) 134 | await set_counter(counter + 1) 135 | return counter 136 | 137 | assert await global_state() == 0 138 | assert await global_state() == 1 139 | 140 | 141 | async def test_class_state_async(async_backend) -> None: 142 | class Bar: 143 | @classmethod 144 | async def class_state(cls) -> int: 145 | counter, set_counter = await async_use_state(0) 146 | await set_counter(counter + 1) 147 | return counter 148 | 149 | bar = Bar() 150 | assert await Bar.class_state() == 0 151 | assert await Bar.class_state() == 1 152 | assert await bar.class_state() == 2 153 | -------------------------------------------------------------------------------- /src/hooks/asyncio/reducers.py: -------------------------------------------------------------------------------- 1 | from typing import Any, Callable, Dict, Optional, Union 2 | 3 | from collections.abc import Awaitable, Coroutine 4 | from functools import wraps 5 | 6 | from ..backends.backend_state import get_hooks_backend 7 | from ..utils import alambda 8 | 9 | 10 | def __async_dispatch_factory( 11 | reducer: Callable[[dict[str, Any], dict[str, Any]], dict[str, Any]], 12 | state_fetcher: Callable[[], dict[str, Any]], 13 | set_state: Callable[[dict[str, Any]], Any], 14 | middleware: Union[ 15 | list[ 16 | Callable[ 17 | [dict[str, Any], Callable[[Any], Any], dict[str, Any]], dict[str, Any] 18 | ] 19 | ], 20 | None, 21 | ] = None, 22 | ) -> Callable[[dict[str, Any]], Coroutine[Any, Any, dict[str, Any]]]: 23 | """ 24 | Create a dispatch function for a reducer. 25 | :param reducer: The reducer to use 26 | :param state_fetcher: The state_fetcher to use 27 | :param set_state: The set_state function to use 28 | :param middleware: The middleware to use 29 | :return: The dispatch function 30 | """ 31 | if middleware is None: 32 | middleware = [] 33 | 34 | async def dispatch(action: dict[str, Any]) -> dict[str, Any]: 35 | """ 36 | Dispatch an action to the reducer. The action will be passed to the middleware. The middleware will be called 37 | in order. The last middleware will call the reducer. The reducer will return the new state. The new state will 38 | be set. 39 | :param action: The action to dispatch 40 | :return: The new state 41 | """ 42 | inner_middleware = middleware 43 | 44 | new_state: dict[str, Any] = state_fetcher() 45 | 46 | if inner_middleware is None: 47 | inner_middleware = [] 48 | 49 | async def reducer_wrapper(inner_state, _, inner_action): 50 | return reducer(inner_state, inner_action) 51 | 52 | inner_middleware.append(reducer_wrapper) 53 | 54 | async def runner(state, middlewares, action): 55 | current = middlewares.pop(0) 56 | return await current( 57 | state, 58 | lambda inner_state, inner_action: alambda( 59 | await runner(inner_state, middlewares, inner_action) for _ in "_" 60 | ), 61 | action, 62 | ) 63 | 64 | state_change: dict[str, Any] = await runner(new_state, inner_middleware, action) 65 | new_state = {**new_state, **state_change} 66 | await set_state(new_state) 67 | return new_state 68 | 69 | return dispatch 70 | 71 | 72 | async def use_reducer( 73 | reducer: Callable[[dict[str, Any], dict[str, Any]], dict[str, Any]], 74 | initial_state: Optional[dict[str, Any]] = None, 75 | middleware: Union[ 76 | list[ 77 | Callable[ 78 | [dict[str, Any], Callable[[Any], Any], dict[str, Any]], 79 | Awaitable[dict[str, Any]], 80 | ] 81 | ], 82 | None, 83 | ] = None, 84 | ) -> tuple[dict[str, Any], Callable[[dict[str, Any]], Awaitable[dict[str, Any]]]]: 85 | """ 86 | Create a reducer hook. The reducer will be called when the dispatch function is called. 87 | :param reducer: The reducer to use 88 | :param initial_state: The initial state to use 89 | :param middleware: The middlewares to use in order, if any 90 | :return: The state and the dispatch function 91 | """ 92 | _backend = get_hooks_backend(using_async=True) 93 | identifier = reducer.__module__ + (reducer.__qualname__ or reducer.__name__) 94 | 95 | async def state_wrapper(value) -> None: 96 | state_wrapper.val = value 97 | await _backend.save(identifier, value) 98 | 99 | def state_fetcher() -> dict[str, Any]: 100 | return state_wrapper.val 101 | 102 | if (await _backend.exists(identifier)) is True: 103 | loaded_value = await _backend.load(identifier) 104 | state_wrapper.val = loaded_value 105 | return state_wrapper.val, __async_dispatch_factory( 106 | reducer, state_fetcher, state_wrapper, middleware or [] 107 | ) 108 | 109 | await state_wrapper(initial_state) 110 | return state_wrapper.val, __async_dispatch_factory( 111 | reducer, state_fetcher, state_wrapper, middleware or [] 112 | ) 113 | 114 | 115 | def combine_reducers( 116 | *reducers: Callable[[dict[str, Any], dict[str, Any]], dict[str, Any]] 117 | ): 118 | """ 119 | Combine multiple reducers into one reducer. 120 | :param reducers: The reducers to combine 121 | :return: The combined reducer 122 | """ 123 | 124 | @wraps(reducers[0]) 125 | def combined_reducer( 126 | state: dict[str, Any], action: dict[str, Any] 127 | ) -> dict[str, Any]: 128 | """ 129 | Combine multiple reducers into one reducer. 130 | :param state: The state to use 131 | :param action: The action to use 132 | :return: The new state 133 | """ 134 | new_state = state 135 | for reducer in reducers: 136 | new_state = reducer(new_state, action) 137 | return new_state 138 | 139 | return combined_reducer 140 | -------------------------------------------------------------------------------- /docs/hooks/additional_hooks/use_reducer.md: -------------------------------------------------------------------------------- 1 | Here is where things get interesting. The `use_reducer` hook is used to manage state in a functional way. 2 | It is similar to the `use_state` hook but allows us to manage more complex state. 3 | 4 | --- 5 | 6 | ### Basic use 7 | 8 | Using the `use_reducer` hook is very similar to reducer concepts in other frameworks. The hook takes in a reducer 9 | function and an initial state. 10 | Intern, the hook returns a tuple containing the current state and a dispatch function. The dispatch function is used to 11 | dispatch actions to the reducer function. 12 | 13 | ```py 14 | from hooks import use_reducer 15 | 16 | 17 | def tasks_reducer(current_state: dict, action: dict) -> dict: 18 | if action["type"] == "ADD_TASK": 19 | return {"tasks": current_state["tasks"] + [action["task"]]} 20 | return current_state 21 | 22 | 23 | state, dispatch = use_reducer(tasks_reducer, {"tasks": []}) 24 | new_state = dispatch({"type": "ADD_TASK", "task": "Do the dishes"}) 25 | 26 | print(state) # Output: {"tasks": []} 27 | print(new_state) # Output: {"tasks": ["Do the dishes"]} 28 | ``` 29 | 30 | The dispatch function can be used to dispatch actions from anywhere in the application. This allows us to manage the 31 | state 32 | in a centralised location. In addition, the use_reducer hook can be used multiple times in the same application and 33 | even in the same function. It knows to identify the correct state to update based on the reducer function passed in. 34 | 35 | --- 36 | 37 | ### Accessing the state in separate functions 38 | 39 | You may use the `use_reducer` hook in separate functions and still access the same state. This is because the hook 40 | uses the same reducer function to identify the state to update. This is useful when you want to separate your logic 41 | into different functions. 42 | 43 | ```python 44 | from hooks import use_reducer 45 | 46 | 47 | def tasks_reducer(current_state: dict, action: dict) -> dict: 48 | if action["type"] == "ADD_TASK": 49 | return {"tasks": current_state["tasks"] + [action["task"]]} 50 | return current_state 51 | 52 | 53 | def add_task(task: str): 54 | state, dispatch = use_reducer(tasks_reducer, {"tasks": []}) 55 | dispatch({"type": "ADD_TASK", "task": task}) 56 | 57 | 58 | def get_tasks(): 59 | state, dispatch = use_reducer(tasks_reducer) 60 | return state["tasks"] 61 | 62 | 63 | add_task("Do the dishes") 64 | add_task("Do the laundry") 65 | print(get_tasks()) # Output: ["Do the dishes", "Do the laundry"] 66 | ``` 67 | 68 | --- 69 | ### Middleware 70 | 71 | The `use_reducer` hook also supports middleware. Middleware is a function that is called before the reducer processes 72 | the action. This allows you to perform actions such as logging or analytics before the reducer processes the action. 73 | 74 | ```python 75 | from hooks import use_reducer 76 | 77 | 78 | def logging_middleware(state: dict, process: Callable, action: dict) -> dict: 79 | print("Action: ", action) 80 | new_state: dict = process(state, action) 81 | print("New state: ", new_state) 82 | return new_state 83 | 84 | 85 | def tasks_reducer(current_state: dict, action: dict) -> dict: 86 | if action["type"] == "ADD_TASK": 87 | return {"tasks": current_state["tasks"] + [action["task"]]} 88 | return current_state 89 | 90 | 91 | state, dispatch = use_reducer(tasks_reducer, {"tasks": []}, [logging_middleware]) 92 | 93 | dispatch({"type": "ADD_TASK", "task": "Do the dishes"}) 94 | # Output: Action: {"type": "ADD_TASK", "task": "Do the dishes"} 95 | # New state: {"tasks": ["Do the dishes"]} 96 | ``` 97 | 98 | You may add as many middleware functions as you like. The middleware functions are called in the order they are 99 | provided. 100 | 101 | --- 102 | ### Combining reducers 103 | 104 | You may also combine multiple reducers into one using the `combine_reducers` function. This is useful when you want to 105 | split your state into multiple reducers. 106 | 107 | ```python 108 | from hooks import use_reducer, combine_reducers 109 | 110 | 111 | def tasks_reducer(current_state: dict, action: dict) -> dict: 112 | if action["type"] == "ADD_TASK": 113 | return {"tasks": current_state["tasks"] + [action["task"]]} 114 | return current_state 115 | 116 | 117 | def user_reducer(current_state: dict, action: dict) -> dict: 118 | if action["type"] == "SET_USER": 119 | return {"user": action["user"]} 120 | return current_state 121 | 122 | 123 | combined_reducer = combine_reducers(tasks_reducer, user_reducer) 124 | state, dispatch = use_reducer(combined_reducer, {"tasks": [], "user": None}) 125 | 126 | dispatch({"type": "ADD_TASK", "task": "Do the dishes"}) 127 | new_state = dispatch({"type": "SET_USER", "user": "John Doe"}) 128 | print(new_state) # Output: {"tasks": ["Do the dishes"], "user": "John Doe"} 129 | ``` 130 | 131 | The `combine_reducers` function takes in multiple reducer functions and returns a single reducer function. The 132 | returned reducer function will call each reducer function with the current state and action. The returned reducer 133 | function will then combine the results of each reducer function into a single state. 134 | 135 | --- 136 | ### Next steps 137 | 138 | Learn about creating [custom hooks](custom_hooks.md). 139 | 140 | Learn about scoping hooks with [hooks_scope](../scoping/scope_decorator.md) decorator. 141 | -------------------------------------------------------------------------------- /docs/scoping/scope_decorator.md: -------------------------------------------------------------------------------- 1 | The `@hook_scope` decorator is used to define a scope for your hooks. It is useful for two main use cases: 2 | 3 | * When you want to persist state based on function parameters. 4 | * When you want to scope hooks in a method globally instead of to the instance or class. 5 | 6 | --- 7 | ### Scoping state to function parameters 8 | 9 | When using hooks inside a function, the scope by default will ignore function parameters. This means that if you call a 10 | hook inside a function, the hook will be scoped to the function and not the function parameters (Unless when using 11 | parameters as dependencies on [use_effect](../hooks/base_hooks/use_effect.md)). 12 | 13 | You can use the `@hook_scope` decorator to scope hooks to function parameters. This is useful when you want to persist 14 | state based on function parameters. 15 | 16 | ```python 17 | from hooks import use_state, hook_scope 18 | 19 | 20 | @hook_scope(parametrize=["owner"]) 21 | def owned_counter(owner: str): 22 | count, set_count = use_state(0) 23 | set_count(count + 1) 24 | print(f"{owner}'s count is {count}") 25 | 26 | owned_counter("John") # Output: John's count is 0 27 | owned_counter("John") # Output: John's count is 1 28 | owned_counter("Jane") # Output: Jane's count is 0 29 | owned_counter("Jane") # Output: Jane's count is 1 30 | ``` 31 | 32 | Note that you do not have to provide a value for the `parametrize` parameter. If you do not provide a value, the hook 33 | will be scoped to all function parameters. 34 | 35 | ```python 36 | from hooks import use_state, hook_scope 37 | 38 | 39 | @hook_scope() 40 | def owned_counter(owner: str): 41 | count, set_count = use_state(0) 42 | set_count(count + 1) 43 | print(f"{owner}'s count is {count}") 44 | 45 | owned_counter("John") # Output: John's count is 0 46 | owned_counter("John") # Output: John's count is 1 47 | owned_counter("Jane") # Output: Jane's count is 0 48 | owned_counter("Jane") # Output: Jane's count is 1 49 | ``` 50 | 51 | --- 52 | ### Scoping hooks globally 53 | 54 | When using hooks inside a class, the default scoping mechanism is to scope the hook to the smallest enclosing scope. 55 | Sometimes, you'll want to scope hooks globally instead of to the instance or class. You can do this by using the 56 | `@hook_scope` decorator. 57 | 58 | ```python 59 | from hooks import use_state, hook_scope 60 | 61 | 62 | class CounterClass: 63 | @hook_scope(use_global_scope=True, parametrize=[]) 64 | def instance_method_scoped_globally(self): 65 | count, set_count = use_state(0) 66 | set_count(count + 1) 67 | return count 68 | 69 | def instance_method(self): 70 | count, set_count = use_state(0) 71 | set_count(count + 1) 72 | return count 73 | 74 | 75 | counter = CounterClass() 76 | counter_two = CounterClass() 77 | print(counter.instance_method()) # Output: 0 78 | print(counter.instance_method()) # Output: 1 79 | print(counter_two.instance_method()) # Output: 0 80 | print(counter.instance_method_scoped_globally()) # Output: 0 81 | print(counter_two.instance_method_scoped_globally()) # Output: 1 82 | 83 | ``` 84 | 85 | Note that when using `use_global_scope=True`, you have to provide a value for the `parametrize` parameter. You may 86 | provide an empty list if you want to scope the hook to no parameters. 87 | 88 | 89 | --- 90 | ### Nesting scopes 91 | 92 | As you might be aware hooks can be used anywhere and you may have functions that use hooks and are called by other 93 | functions that use hooks. When nesting hooks like this, scoping will affect all hooks in the call stack. 94 | 95 | ```python 96 | from hooks import use_state, hook_scope 97 | 98 | class NestedStates: 99 | def nested_state(self) -> int: 100 | counter, set_counter = use_state(0) 101 | set_counter(counter + 1) 102 | return counter 103 | 104 | @hook_scope(parametrize=["counter_name"]) 105 | def local_state(self, counter_name: str) -> int: 106 | return self.nested_state() 107 | 108 | foo = NestedStates() 109 | 110 | print(foo.local_state("A")) # Output: 0 111 | print(foo.local_state("A")) # Output: 1 112 | 113 | # As you can see the scope of local_state affects the nested_state's function hooks. 114 | print(foo.local_state("B")) # Output: 0 115 | print(foo.local_state("B")) # Output: 1 116 | print(foo.local_state("A")) # Output: 2 117 | 118 | print(NestedStates().local_state("A")) # Output: 0 119 | print(NestedStates().local_state("A")) # Output: 0 120 | 121 | ``` 122 | 123 | The same concept can be taken further by scoping the nested_state function. 124 | 125 | ```python 126 | from hooks import use_state, hook_scope 127 | 128 | class NestedStates: 129 | @hook_scope() 130 | def nested_state_with_scope(self) -> int: 131 | counter, set_counter = use_state(0) 132 | set_counter(counter + 1) 133 | return counter 134 | 135 | @hook_scope(parametrize=["counter_name"]) 136 | def local_state(self, counter_name: str) -> int: 137 | return self.nested_state_with_scope() 138 | 139 | foo = NestedStates() 140 | 141 | print(foo.local_state("A")) # Output: 0 142 | print(foo.local_state("A")) # Output: 1 143 | 144 | print(foo.local_state("B")) # Output: 0 145 | print(foo.local_state("B")) # Output: 1 146 | print(foo.local_state("A")) # Output: 2 147 | 148 | print(NestedStates().local_state("A")) # Output: 0 149 | print(NestedStates().local_state("A")) # Output: 0 150 | ``` 151 | 152 | --- 153 | 154 | ### Next steps 155 | 156 | Learn about [backends](../backends/default.md). 157 | -------------------------------------------------------------------------------- /tests/test_hooks/test_use_reducer.py: -------------------------------------------------------------------------------- 1 | # mypy: ignore-errors 2 | 3 | from typing import Any, Callable 4 | 5 | from collections.abc import Awaitable 6 | from unittest.mock import Mock 7 | 8 | from hooks.asyncio.reducers import use_reducer as async_use_reducer 9 | from hooks.reducers import combine_reducers, use_reducer 10 | 11 | 12 | def test_simple_state_mutation() -> None: 13 | def tasks_reducer( 14 | current_state: dict[str, Any], action: dict[str, Any] 15 | ) -> dict[str, Any]: 16 | if action["type"] == "ADD_TASK": 17 | return {"tasks": current_state["tasks"] + [action["task"]]} 18 | return current_state 19 | 20 | state, dispatch = use_reducer(tasks_reducer, {"tasks": []}) 21 | new_state = dispatch({"type": "ADD_TASK", "task": "Do the dishes"}) 22 | 23 | assert state == {"tasks": []}, "The original state should not be mutated" 24 | assert new_state == {"tasks": ["Do the dishes"]}, "The new state should be mutated" 25 | 26 | 27 | def test_simple_middleware() -> None: 28 | mock = Mock() 29 | 30 | def logging_middleware( 31 | state: dict[str, Any], next: Callable[[Any, Any], Any], action: dict[str, Any] 32 | ) -> dict[str, Any]: 33 | mock() 34 | new_state: dict[str, Any] = next(state, action) 35 | mock() 36 | return new_state 37 | 38 | def tasks_reducer( 39 | current_state: dict[str, Any], action: dict[str, Any] 40 | ) -> dict[str, Any]: 41 | if action["type"] == "ADD_TASK": 42 | return {"tasks": current_state["tasks"] + [action["task"]]} 43 | return current_state 44 | 45 | state, dispatch = use_reducer(tasks_reducer, {"tasks": []}, [logging_middleware]) 46 | 47 | dispatch({"type": "ADD_TASK", "task": "Do the dishes"}) 48 | 49 | assert mock.call_count == 2 50 | 51 | 52 | def test_simple_state_mutation_multiple_calls() -> None: 53 | def tasks_reducer( 54 | current_state: dict[str, Any], action: dict[str, Any] 55 | ) -> dict[str, Any]: 56 | if action["type"] == "ADD_TASK": 57 | return {"tasks": current_state["tasks"] + [action["task"]]} 58 | return current_state 59 | 60 | state, dispatch = use_reducer(tasks_reducer, {"tasks": []}) 61 | 62 | dispatch({"type": "ADD_TASK", "task": "Do the dishes"}) 63 | 64 | # .... Other section in the program 65 | 66 | state, dispatch = use_reducer(tasks_reducer) 67 | 68 | assert state == {"tasks": ["Do the dishes"]}, "The new state should be mutated" 69 | 70 | 71 | def test_combine_reducers() -> None: 72 | def tasks_reducer(current_state: dict, action: dict) -> dict: 73 | if action["type"] == "ADD_TASK": 74 | return {"tasks": current_state["tasks"] + [action["task"]]} 75 | return current_state 76 | 77 | def user_reducer(current_state: dict, action: dict) -> dict: 78 | if action["type"] == "SET_USER": 79 | return {"user": action["user"]} 80 | return current_state 81 | 82 | combined_reducer = combine_reducers(tasks_reducer, user_reducer) 83 | state, dispatch = use_reducer(combined_reducer, {"tasks": [], "user": None}) 84 | 85 | dispatch({"type": "ADD_TASK", "task": "Do the dishes"}) 86 | dispatch({"type": "SET_USER", "user": "John Doe"}) 87 | 88 | state, _ = use_reducer(combined_reducer) 89 | 90 | assert state == { 91 | "tasks": ["Do the dishes"], 92 | "user": "John Doe", 93 | }, "The new state should be mutated" 94 | 95 | 96 | async def test_simple_state_mutation_async(async_backend) -> None: 97 | def tasks_reducer( 98 | current_state: dict[str, Any], action: dict[str, Any] 99 | ) -> dict[str, Any]: 100 | if action["type"] == "ADD_TASK": 101 | return {"tasks": current_state["tasks"] + [action["task"]]} 102 | return current_state 103 | 104 | state, dispatch = await async_use_reducer(tasks_reducer, {"tasks": []}) 105 | new_state = await dispatch({"type": "ADD_TASK", "task": "Do the dishes"}) 106 | 107 | assert state == {"tasks": []}, "The original state should not be mutated" 108 | assert new_state == {"tasks": ["Do the dishes"]}, "The new state should be mutated" 109 | 110 | 111 | async def test_simple_middleware_async(async_backend) -> None: 112 | mock = Mock() 113 | 114 | async def logging_middleware( 115 | state: dict[str, Any], 116 | next: Callable[[Any, Any], Awaitable[Any]], 117 | action: dict[str, Any], 118 | ) -> dict[str, Any]: 119 | mock() 120 | new_state: dict[str, Any] = await next(state, action) 121 | mock() 122 | return new_state 123 | 124 | def tasks_reducer( 125 | current_state: dict[str, Any], action: dict[str, Any] 126 | ) -> dict[str, Any]: 127 | if action["type"] == "ADD_TASK": 128 | return {"tasks": current_state["tasks"] + [action["task"]]} 129 | return current_state 130 | 131 | state, dispatch = await async_use_reducer( 132 | tasks_reducer, {"tasks": []}, [logging_middleware] 133 | ) 134 | 135 | await dispatch({"type": "ADD_TASK", "task": "Do the dishes"}) 136 | 137 | assert mock.call_count == 2 138 | 139 | 140 | async def test_simple_state_mutation_multiple_calls_async(async_backend) -> None: 141 | def tasks_reducer( 142 | current_state: dict[str, Any], action: dict[str, Any] 143 | ) -> dict[str, Any]: 144 | if action["type"] == "ADD_TASK": 145 | return {"tasks": current_state["tasks"] + [action["task"]]} 146 | return current_state 147 | 148 | state, dispatch = await async_use_reducer(tasks_reducer, {"tasks": []}) 149 | 150 | await dispatch({"type": "ADD_TASK", "task": "Do the dishes"}) 151 | 152 | # .... Other section in the program 153 | 154 | state, dispatch = await async_use_reducer(tasks_reducer) 155 | 156 | assert state == {"tasks": ["Do the dishes"]}, "The new state should be mutated" 157 | -------------------------------------------------------------------------------- /src/hooks/frame_utils.py: -------------------------------------------------------------------------------- 1 | # mypy: ignore-errors 2 | 3 | from __future__ import annotations 4 | 5 | from types import FrameType, FunctionType, MethodType 6 | from typing import Any, Callable 7 | 8 | import sys 9 | 10 | from pyhashxx import hashxx 11 | 12 | from .backends.backend_state import get_hooks_backend 13 | from .backends.interface import HooksBackend 14 | from .backends.python_objects_backend import ( 15 | async_python_object_backend_factory, 16 | python_object_backend_factory, 17 | ) 18 | from .scope import HOOKED_FUNCTION_ATTRIBUTE, _hook_scope_manager 19 | 20 | SPECIAL_HOOKS = ["create_context"] 21 | 22 | 23 | def __identify_function_and_owner( 24 | frame: FrameType | None | Any, 25 | ) -> tuple[Callable[[Any], Any] | None, Any]: 26 | """ 27 | Find the owner of the function that called the current function frame. If the function is a method, the owner is 28 | the class of the instance. If the function is a static method, the owner is the class of the static method. 29 | :param frame: The frame of the function that called the current function 30 | :return: The function instance and the owner of the function that called the current function frame 31 | """ 32 | at: dict[str, Any] = {**frame.f_globals, **frame.f_locals} 33 | value: Any = None 34 | if hasattr(frame.f_code, "co_qualname"): 35 | for part in frame.f_code.co_qualname.split(".")[:-1]: 36 | for name, value in at.items(): 37 | if name == part: 38 | at = value.__dict__ 39 | break 40 | else: 41 | caller_name = frame.f_code.co_name 42 | # In the case we cannot find the owner using the qualname which is safest, we try to find the owner using the 43 | # function args. This is not safe because the function args can be anything, but it is better than nothing. 44 | for arg, arg_value in frame.f_locals.items(): 45 | if hasattr(arg_value, caller_name) and isinstance( 46 | getattr(arg_value, caller_name), (FunctionType, MethodType) 47 | ): 48 | return getattr(arg_value, caller_name), arg_value 49 | 50 | # Finally, we will try to find a hooked function in the frame stack which is provided by the hook decorator to 51 | # help us limit the scope of hooks for global functions without owners. 52 | frame = frame.f_back 53 | if frame: 54 | for arg, arg_value in frame.f_locals.items(): 55 | if arg == HOOKED_FUNCTION_ATTRIBUTE: 56 | return arg_value, None 57 | 58 | if frame: 59 | return at.get(frame.f_code.co_name, None), value 60 | return None, value 61 | 62 | 63 | def __frame_parts_to_identifier(*args: Any) -> str: 64 | """ 65 | Get a unique identifier for the frame. This is used to identify the hook that called the current function frame. 66 | :return: A unique identifier for the frame 67 | """ 68 | return "".join(map(str, args)) 69 | 70 | 71 | # type: ignore 72 | def __identify_hook_and_backend( 73 | always_global_backend: bool = False, 74 | prefix: str = "", 75 | using_async: bool = False, 76 | ) -> tuple[str, type[HooksBackend] | HooksBackend]: 77 | """ 78 | Identify the hook that called the current function frame and the backend that should be used to backend the hook's 79 | state. If the hook is called from a method, the backend is a PythonObjectBackend. If the hook is called from a static 80 | method, the backend is a PickleBackend. If the hook is called from a function, the backend is a PickleBackend. 81 | :param always_global_backend: If True, the backend will always be a PickleBackend regardless of the hook's caller 82 | :param prefix: A prefix to add to the hook identifier 83 | :param using_async: Whether the hook is used in an async context 84 | :return: The hook identifier and the backend that should be used to backend the hook's state 85 | """ 86 | # The use of _getframe is not ideal, but it is more performant than using inspect.currentframe 87 | frame: FrameType | None | Any = sys._getframe() 88 | if not frame: 89 | raise RuntimeError( 90 | "Could not identify the hook that called the current function" 91 | ) 92 | 93 | frame: FrameType | None | Any = frame.f_back.f_back 94 | 95 | if not frame: 96 | raise RuntimeError( 97 | "Could not identify the hook that called the current function" 98 | ) 99 | 100 | identifier_prefix = "" 101 | 102 | # Skip all hook functions in order to identify the function that called the hook 103 | while ( 104 | frame.f_code.co_name.startswith("use_") 105 | or frame.f_globals["__name__"].startswith("hooks.") 106 | ) and frame.f_code.co_name not in SPECIAL_HOOKS: 107 | # We add a prefix to the identifier to ensure that the identifier is unique and that we can use hooks inside 108 | # hooks 109 | identifier_prefix += __frame_parts_to_identifier( 110 | frame.f_code.co_filename, frame.f_lineno, frame.f_code.co_name 111 | ) 112 | frame = frame.f_back 113 | 114 | frame_identifier = identifier_prefix + __frame_parts_to_identifier( 115 | frame.f_code.co_filename, frame.f_lineno, frame.f_code.co_name 116 | ) 117 | 118 | # We identify the function that called the hook and its owner 119 | caller_function, owner = __identify_function_and_owner(frame) 120 | 121 | # We identify the type of the function that called the hook 122 | is_static_method = isinstance(caller_function, staticmethod) 123 | is_class_method = isinstance(caller_function, classmethod) 124 | is_method = owner is not None and not is_static_method and not is_class_method 125 | is_scoped_globally = hasattr(caller_function, "use_global_scope") and getattr( 126 | caller_function, "use_global_scope", False 127 | ) 128 | always_global_backend = always_global_backend or is_scoped_globally 129 | 130 | # In case the owner we found is a class, we try to find the instance of the class in the frame locals (self) 131 | if (is_method or is_class_method) and getattr(owner, "__class__", None) == type: 132 | for _, value in frame.f_locals.items(): 133 | if isinstance(value, owner): 134 | owner = value 135 | break 136 | 137 | # Always add the current hook scope identifier to the frame identifier 138 | frame_identifier += ";".join(_hook_scope_manager.current_identifier) 139 | 140 | # If the hook is called from a method, we use a PythonObjectBackend to backend the hook's state. 141 | if (not is_method and not is_class_method) or always_global_backend: 142 | _backend = get_hooks_backend(using_async=using_async) 143 | return ( 144 | str(hashxx(f"{prefix}{frame_identifier}".encode())), 145 | _backend, 146 | ) 147 | 148 | return ( 149 | str(hashxx(f"{prefix}{frame_identifier}{frame.f_code.co_name}".encode())), 150 | python_object_backend_factory(owner) 151 | if not using_async 152 | else async_python_object_backend_factory(owner), 153 | ) 154 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Python Hooks 2 | 3 |
4 | 5 | [![Build status](https://github.com/amitassaraf/python-hooks/workflows/build/badge.svg?branch=master&event=push)](https://github.com/amitassaraf/python-hooks/actions?query=workflow%3Abuild) 6 | [![Python Version](https://img.shields.io/pypi/pyversions/python-hooks.svg)](https://pypi.org/project/python-hooks/) 7 | [![Dependencies Status](https://img.shields.io/badge/dependencies-up%20to%20date-brightgreen.svg)](https://github.com/amitassaraf/python-hooks/pulls?utf8=%E2%9C%93&q=is%3Apr%20author%3Aapp%2Fdependabot) 8 | [![Code style: black](https://img.shields.io/badge/code%20style-black-000000.svg)](https://github.com/psf/black) 9 | [![Security: bandit](https://img.shields.io/badge/security-bandit-green.svg)](https://github.com/PyCQA/bandit) 10 | [![Pre-commit](https://img.shields.io/badge/pre--commit-enabled-brightgreen?logo=pre-commit&logoColor=white)](https://github.com/amitassaraf/python-hooks/blob/master/.pre-commit-config.yaml) 11 | [![Semantic Versions](https://img.shields.io/badge/%20%20%F0%9F%93%A6%F0%9F%9A%80-semantic--versions-e10079.svg)](https://github.com/amitassaraf/python-hooks/releases) 12 | [![License](https://img.shields.io/github/license/amitassaraf/python-hooks)](https://github.com/amitassaraf/python-hooks/blob/master/LICENSE) 13 | ![Coverage Report](assets/images/coverage.svg) 14 | 15 | GitHub Contributors 16 | 17 | 18 | Issues 19 | 20 | 21 | GitHub pull requests 22 | 23 | Dev status 24 | 25 | --- 26 | 27 | 28 | ### A React inspired way to code in Python. 29 |
30 | 31 |
32 | drawing 33 |
34 |
35 | 36 | Made with love by Amit Assaraf 37 | 38 |
39 | If you like this project, consider starring it on Github ⭐⭐⭐⭐ 40 |
41 |
42 | 43 | * ⚡️ Written with performance in mind __(Not using inspect module)__ 44 | * 🐍 Support for familiar React like hooks such as `use_state`, `use_effect`, `use_reducer`, `use_context` and more. 45 | * 🛠️ Built with plugins support and easy to extend. 46 | * 🤝 Python 3.9+ compatibility 47 | * 🪶 Lightweight to remove the need for a lot of boilerplate code. 48 | * 📦 Support for different backend state-stores. 49 | * 🔧 Support for hooks-state scoping. 50 | * 🌎 Redis, Redux, and Zustand plugins out of the box. 51 | * 🔌 Tested mainly on CPython. 52 | 53 | --- 54 | 55 | ## Inspiration 56 | 57 | I'll begin be prefacing that this project will not fit everyone's needs. It's a very opinionated project and it's not meant to be a replacement for any of the existing state management libraries. 58 | Additionally, if you are trying to squeeze the most performance out of Python this is probably not the right tool for you _(Though you might also be using the wrong programming language)_. 59 | 60 | The goal of the project is to provide a simple and familiar way to manage state in Python. It's inspired by the React hooks API and it's meant to be used in a similar way. It can be used to manage 61 | state in a single function or in a large project. The state can be stored in memory, in a database or in a file. It's up to you to decide and the library is easily extendable to support any backend. 62 | 63 | One of the inspirations for this project was seeing the way junior developers struggle with state management and program structure in Python. This library is intended to provide a sense of familiarity 64 | to developers coming from the JavaScript world and to provide a simple way to manage state in Python which will not require a lot of boilerplate code or hurt performance. 65 | 66 | It is meant to help write better data-driven code and to help developers focus on the business logic of their code rather than on the plumbing. 67 | 68 | 69 | [**Discord**](https://discord.gg/mayParnv) | [**Docs**](https://amitassaraf.github.io/python-hooks/) 70 | 71 | 72 | 73 | Note: This project is still in beta and is not recommended for production use just yet. We are working on adding more tests and improving the documentation. If you are interested in contributing, please reach out to me on [Discord](https://discord.gg/mayParnv). 74 | The biggest challenge right now is to find a way to handle code versioning, as the state is identified by the frame in which it was created. This means that if you change the code, the state will be lost. 75 | 76 | --- 77 | 78 | ## Let's see one API example 79 | 80 | As Python is commonly used to create APIs, I wanted to provide one real-world example of using hooks for state management in Flask. Note, that this is just one example and the library can be used in many other ways. See [examples](https://github.com/amitassaraf/python-hooks/tree/master/examples) 81 | for more examples. 82 | 83 | drawing 84 | 85 | ### Pretty neat, right? 86 | 87 | 88 | ## 🚀 Installation 89 | 90 | ```bash 91 | pip install python-hooks 92 | ``` 93 | 94 | ## 🎯 What's next 95 | 96 | Well, that's up to you 💪🏻. We are looking for contributors to help us build this project and make it better. If you are interested in contributing, please reach out to me on [Discord](https://discord.gg/mayParnv) or open an issue. 97 | In addition we are looking for plugin developers to help us build more plugins for different state stores. 98 | 99 | ## 📈 Releases 100 | 101 | You can see the list of available releases on the [GitHub Releases](https://github.com/amitassaraf/python-hooks/releases) page. 102 | 103 | We follow [Semantic Versions](https://semver.org/) specification. 104 | 105 | We use [`Release Drafter`](https://github.com/marketplace/actions/release-drafter). As pull requests are merged, a draft release is kept up-to-date listing the changes, ready to publish when you’re ready. With the categories option, you can categorize pull requests in release notes using labels. 106 | 107 | ### List of labels and corresponding titles 108 | 109 | | **Label** | **Title in Releases** | 110 | | :-----------------------------------: | :---------------------: | 111 | | `enhancement`, `feature` | 🚀 Features | 112 | | `bug`, `refactoring`, `bugfix`, `fix` | 🔧 Fixes & Refactoring | 113 | | `build`, `ci`, `testing` | 📦 Build System & CI/CD | 114 | | `breaking` | 💥 Breaking Changes | 115 | | `documentation` | 📝 Documentation | 116 | | `dependencies` | ⬆️ Dependencies updates | 117 | 118 | 119 | GitHub creates the `bug`, `enhancement`, and `documentation` labels for you. Dependabot creates the `dependencies` label. Create the remaining labels on the Issues tab of your GitHub repository, when you need them. 120 | 121 | 122 | ## 📋 Roadmap 123 | 124 | - [x] Finish documentation 125 | - [x] Improve frame identifier without hurting performance 126 | - [x] Add redux plugin 127 | - [x] Async support ⚡ 128 | - [ ] Handle code versioning (Moving state around the code) [!] 129 | - [ ] Develop state-debugger plugin 130 | - [ ] Test and adapt to PyPy, Jython, IronPython 131 | - [ ] Support for more hook backends 132 | - [ ] MongoDB 133 | - [ ] Postgres 134 | - [ ] MySQL 135 | - [ ] SQLite 136 | 137 | See the [open issues](https://github.com/amitassaraf/python-hooks/issues) for a full list of proposed features (and known issues). 138 | 139 | ## 🛡 License 140 | 141 | [![License](https://img.shields.io/github/license/amitassaraf/python-hooks)](https://github.com/amitassaraf/python-hooks/blob/master/LICENSE) 142 | 143 | This project is licensed under the terms of the `MIT` license. See [LICENSE](https://github.com/amitassaraf/python-hooks/blob/master/LICENSE) for more details. 144 | 145 | ## 📃 Citation 146 | 147 | ```bibtex 148 | @misc{python-hooks, 149 | author = {Amit Assaraf}, 150 | title = {A React inspired way to code in Python}, 151 | year = {2023}, 152 | publisher = {GitHub}, 153 | journal = {GitHub repository}, 154 | howpublished = {\url{https://github.com/amitassaraf/python-hooks}} 155 | } 156 | ``` 157 | -------------------------------------------------------------------------------- /tests/test_hooks/test_hook_scope.py: -------------------------------------------------------------------------------- 1 | from hooks.asyncio.use import use_state as async_use_state 2 | from hooks.scope import hook_scope 3 | from hooks.use import use_state 4 | 5 | 6 | def test_local_state() -> None: 7 | class Foo: 8 | @hook_scope(parametrize=["counter_name"]) 9 | def local_state(self, counter_name: str) -> int: 10 | counter, set_counter = use_state(0) 11 | set_counter(counter + 1) 12 | return counter 13 | 14 | foo = Foo() 15 | 16 | assert foo.local_state("A") == 0 17 | assert foo.local_state("A") == 1 18 | assert foo.local_state("B") == 0 19 | assert foo.local_state("B") == 1 20 | assert foo.local_state("A") == 2 21 | assert Foo().local_state("A") == 0 22 | assert Foo().local_state("A") == 0 23 | 24 | 25 | def test_global_state() -> None: 26 | class Foo: 27 | @staticmethod 28 | @hook_scope() 29 | def global_state(counter_name: str) -> int: 30 | counter, set_counter = use_state(0) 31 | set_counter(counter + 1) 32 | return counter 33 | 34 | foo = Foo() 35 | 36 | assert Foo.global_state("A") == 0 37 | assert foo.global_state("A") == 1 38 | assert Foo().global_state("A") == 2 39 | assert Foo().global_state("B") == 0 40 | assert Foo().global_state("B") == 1 41 | 42 | 43 | def test_global_state_without_arguments() -> None: 44 | @hook_scope() 45 | def global_state() -> int: 46 | counter, set_counter = use_state(0) 47 | set_counter(counter + 1) 48 | return counter 49 | 50 | assert global_state() == 0 51 | assert global_state() == 1 52 | 53 | 54 | def test_class_state() -> None: 55 | class Bar: 56 | @classmethod 57 | @hook_scope(parametrize=["counter_name"]) 58 | def class_state(cls, counter_name: str) -> int: 59 | counter, set_counter = use_state(0) 60 | set_counter(counter + 1) 61 | return counter 62 | 63 | bar = Bar() 64 | assert Bar.class_state("A") == 0 65 | assert Bar.class_state("A") == 1 66 | assert bar.class_state("A") == 2 67 | assert bar.class_state("B") == 0 68 | assert bar.class_state("B") == 1 69 | 70 | 71 | def test_hook_scope_of_nested_functions() -> None: 72 | class Foo: 73 | def nested_state(self) -> int: 74 | counter, set_counter = use_state(0) 75 | set_counter(counter + 1) 76 | return counter 77 | 78 | @hook_scope(parametrize=["counter_name"]) 79 | def local_state(self, counter_name: str) -> int: 80 | return self.nested_state() 81 | 82 | foo = Foo() 83 | 84 | assert foo.local_state("A") == 0 85 | assert foo.local_state("A") == 1 86 | assert foo.local_state("B") == 0 87 | assert foo.local_state("B") == 1 88 | assert foo.local_state("A") == 2 89 | assert Foo().local_state("A") == 0 90 | assert Foo().local_state("A") == 0 91 | 92 | 93 | def test_nested_hook_scopes() -> None: 94 | class Foo: 95 | @hook_scope() 96 | def nested_state_with_scope(self) -> int: 97 | counter, set_counter = use_state(0) 98 | set_counter(counter + 1) 99 | return counter 100 | 101 | @hook_scope(parametrize=["counter_name"]) 102 | def local_state(self, counter_name: str) -> int: 103 | return self.nested_state_with_scope() 104 | 105 | foo = Foo() 106 | 107 | assert foo.local_state("A") == 0 108 | assert foo.local_state("A") == 1 109 | assert foo.local_state("B") == 0 110 | assert foo.local_state("B") == 1 111 | assert foo.local_state("A") == 2 112 | assert Foo().local_state("A") == 0 113 | assert Foo().local_state("A") == 0 114 | 115 | 116 | def test_local_state_that_is_scoped_globally() -> None: 117 | class Foo: 118 | @hook_scope(use_global_scope=True, parametrize=[]) 119 | def local_state(self) -> int: 120 | counter, set_counter = use_state(0) 121 | set_counter(counter + 1) 122 | return counter 123 | 124 | foo = Foo() 125 | 126 | assert foo.local_state() == 0 127 | assert foo.local_state() == 1 128 | assert Foo().local_state() == 2 129 | assert Foo().local_state() == 3 130 | 131 | 132 | ######################################################################################################################## 133 | # Asynchronous 134 | ######################################################################################################################## 135 | 136 | 137 | async def test_local_state_async(async_backend) -> None: 138 | class Foo: 139 | @hook_scope(parametrize=["counter_name"]) 140 | async def local_state(self, counter_name: str) -> int: 141 | counter, set_counter = await async_use_state(0) 142 | await set_counter(counter + 1) 143 | return counter 144 | 145 | foo = Foo() 146 | 147 | assert await foo.local_state("A") == 0 148 | assert await foo.local_state("A") == 1 149 | assert await foo.local_state("B") == 0 150 | assert await foo.local_state("B") == 1 151 | assert await foo.local_state("A") == 2 152 | assert await Foo().local_state("A") == 0 153 | assert await Foo().local_state("A") == 0 154 | 155 | 156 | async def test_global_state_async(async_backend) -> None: 157 | class Foo: 158 | @staticmethod 159 | @hook_scope() 160 | async def global_state(counter_name: str) -> int: 161 | counter, set_counter = await async_use_state(0) 162 | await set_counter(counter + 1) 163 | return counter 164 | 165 | foo = Foo() 166 | 167 | assert await Foo.global_state("A") == 0 168 | assert await foo.global_state("A") == 1 169 | assert await Foo().global_state("A") == 2 170 | assert await Foo().global_state("B") == 0 171 | assert await Foo().global_state("B") == 1 172 | 173 | 174 | async def test_global_state_without_arguments_async(async_backend) -> None: 175 | @hook_scope() 176 | async def global_state() -> int: 177 | counter, set_counter = await async_use_state(0) 178 | await set_counter(counter + 1) 179 | return counter 180 | 181 | assert await global_state() == 0 182 | assert await global_state() == 1 183 | 184 | 185 | async def test_class_state_async(async_backend) -> None: 186 | class Bar: 187 | @classmethod 188 | @hook_scope(parametrize=["counter_name"]) 189 | async def class_state(cls, counter_name: str) -> int: 190 | counter, set_counter = await async_use_state(0) 191 | await set_counter(counter + 1) 192 | return counter 193 | 194 | bar = Bar() 195 | assert await Bar.class_state("A") == 0 196 | assert await Bar.class_state("A") == 1 197 | assert await bar.class_state("A") == 2 198 | assert await bar.class_state("B") == 0 199 | assert await bar.class_state("B") == 1 200 | 201 | 202 | async def test_hook_scope_of_nested_functions_async(async_backend) -> None: 203 | class Foo: 204 | async def nested_state(self) -> int: 205 | counter, set_counter = await async_use_state(0) 206 | await set_counter(counter + 1) 207 | return counter 208 | 209 | @hook_scope(parametrize=["counter_name"]) 210 | async def local_state(self, counter_name: str) -> int: 211 | return await self.nested_state() 212 | 213 | foo = Foo() 214 | 215 | assert await foo.local_state("A") == 0 216 | assert await foo.local_state("A") == 1 217 | assert await foo.local_state("B") == 0 218 | assert await foo.local_state("B") == 1 219 | assert await foo.local_state("A") == 2 220 | assert await Foo().local_state("A") == 0 221 | assert await Foo().local_state("A") == 0 222 | 223 | 224 | async def test_nested_hook_scopes_async(async_backend) -> None: 225 | class Foo: 226 | @hook_scope() 227 | async def nested_state_with_scope(self) -> int: 228 | counter, set_counter = await async_use_state(0) 229 | await set_counter(counter + 1) 230 | return counter 231 | 232 | @hook_scope(parametrize=["counter_name"]) 233 | async def local_state(self, counter_name: str) -> int: 234 | return await self.nested_state_with_scope() 235 | 236 | foo = Foo() 237 | 238 | assert await foo.local_state("A") == 0 239 | assert await foo.local_state("A") == 1 240 | assert await foo.local_state("B") == 0 241 | assert await foo.local_state("B") == 1 242 | assert await foo.local_state("A") == 2 243 | assert await Foo().local_state("A") == 0 244 | assert await Foo().local_state("A") == 0 245 | 246 | 247 | async def test_local_state_that_is_scoped_globally_async(async_backend) -> None: 248 | class Foo: 249 | @hook_scope(use_global_scope=True, parametrize=[]) 250 | async def local_state(self) -> int: 251 | counter, set_counter = await async_use_state(0) 252 | await set_counter(counter + 1) 253 | return counter 254 | 255 | foo = Foo() 256 | 257 | assert await foo.local_state() == 0 258 | assert await foo.local_state() == 1 259 | assert await Foo().local_state() == 2 260 | assert await Foo().local_state() == 3 261 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | 2 | # Created by https://www.gitignore.io/api/osx,python,pycharm,windows,visualstudio,visualstudiocode 3 | # Edit at https://www.gitignore.io/?templates=osx,python,pycharm,windows,visualstudio,visualstudiocode 4 | 5 | ### OSX ### 6 | # General 7 | .DS_Store 8 | .AppleDouble 9 | .LSOverride 10 | 11 | # Icon must end with two \r 12 | Icon 13 | 14 | # Thumbnails 15 | ._* 16 | 17 | # Files that might appear in the root of a volume 18 | .DocumentRevisions-V100 19 | .fseventsd 20 | .Spotlight-V100 21 | .TemporaryItems 22 | .Trashes 23 | .VolumeIcon.icns 24 | .com.apple.timemachine.donotpresent 25 | 26 | # Directories potentially created on remote AFP share 27 | .AppleDB 28 | .AppleDesktop 29 | Network Trash Folder 30 | Temporary Items 31 | .apdisk 32 | 33 | ### PyCharm ### 34 | # Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio and WebStorm 35 | # Reference: https://intellij-support.jetbrains.com/hc/en-us/articles/206544839 36 | 37 | # User-specific stuff 38 | .idea/**/workspace.xml 39 | .idea/**/tasks.xml 40 | .idea/**/usage.statistics.xml 41 | .idea/**/dictionaries 42 | .idea/**/shelf 43 | 44 | # Generated files 45 | .idea/**/contentModel.xml 46 | 47 | # Sensitive or high-churn files 48 | .idea/**/dataSources/ 49 | .idea/**/dataSources.ids 50 | .idea/**/dataSources.local.xml 51 | .idea/**/sqlDataSources.xml 52 | .idea/**/dynamic.xml 53 | .idea/**/uiDesigner.xml 54 | .idea/**/dbnavigator.xml 55 | 56 | # Gradle 57 | .idea/**/gradle.xml 58 | .idea/**/libraries 59 | 60 | # Gradle and Maven with auto-import 61 | # When using Gradle or Maven with auto-import, you should exclude module files, 62 | # since they will be recreated, and may cause churn. Uncomment if using 63 | # auto-import. 64 | # .idea/modules.xml 65 | # .idea/*.iml 66 | # .idea/modules 67 | # *.iml 68 | # *.ipr 69 | 70 | # CMake 71 | cmake-build-*/ 72 | 73 | # Mongo Explorer plugin 74 | .idea/**/mongoSettings.xml 75 | 76 | # File-based project format 77 | *.iws 78 | 79 | # IntelliJ 80 | out/ 81 | 82 | # mpeltonen/sbt-idea plugin 83 | .idea_modules/ 84 | 85 | # JIRA plugin 86 | atlassian-ide-plugin.xml 87 | 88 | # Cursive Clojure plugin 89 | .idea/replstate.xml 90 | 91 | # Crashlytics plugin (for Android Studio and IntelliJ) 92 | com_crashlytics_export_strings.xml 93 | crashlytics.properties 94 | crashlytics-build.properties 95 | fabric.properties 96 | 97 | # Editor-based Rest Client 98 | .idea/httpRequests 99 | 100 | # Android studio 3.1+ serialized cache file 101 | .idea/caches/build_file_checksums.ser 102 | 103 | ### PyCharm Patch ### 104 | # Comment Reason: https://github.com/joeblau/gitignore.io/issues/186#issuecomment-215987721 105 | 106 | # *.iml 107 | # modules.xml 108 | # .idea/misc.xml 109 | # *.ipr 110 | 111 | # Sonarlint plugin 112 | .idea/**/sonarlint/ 113 | 114 | # SonarQube Plugin 115 | .idea/**/sonarIssues.xml 116 | 117 | # Markdown Navigator plugin 118 | .idea/**/markdown-navigator.xml 119 | .idea/**/markdown-navigator/ 120 | 121 | ### Python ### 122 | # Byte-compiled / optimized / DLL files 123 | __pycache__/ 124 | *.py[cod] 125 | *$py.class 126 | 127 | # C extensions 128 | *.so 129 | 130 | # Distribution / packaging 131 | .Python 132 | build/ 133 | develop-eggs/ 134 | dist/ 135 | downloads/ 136 | eggs/ 137 | .eggs/ 138 | lib/ 139 | lib64/ 140 | parts/ 141 | sdist/ 142 | var/ 143 | wheels/ 144 | pip-wheel-metadata/ 145 | share/python-wheels/ 146 | *.egg-info/ 147 | .installed.cfg 148 | *.egg 149 | MANIFEST 150 | 151 | # PyInstaller 152 | # Usually these files are written by a python script from a template 153 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 154 | *.manifest 155 | *.spec 156 | 157 | # Installer logs 158 | pip-log.txt 159 | pip-delete-this-directory.txt 160 | 161 | # Unit test / coverage reports 162 | htmlcov/ 163 | .tox/ 164 | .nox/ 165 | .coverage 166 | .coverage.* 167 | .cache 168 | nosetests.xml 169 | coverage.xml 170 | *.cover 171 | .hypothesis/ 172 | .pytest_cache/ 173 | 174 | # Translations 175 | *.mo 176 | *.pot 177 | 178 | # Scrapy stuff: 179 | .scrapy 180 | 181 | # Sphinx documentation 182 | docs/_build/ 183 | 184 | # PyBuilder 185 | target/ 186 | 187 | # pyenv 188 | .python-version 189 | 190 | # poetry 191 | .venv 192 | 193 | # pipenv 194 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 195 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 196 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 197 | # install all needed dependencies. 198 | #Pipfile.lock 199 | 200 | # celery beat schedule file 201 | celerybeat-schedule 202 | 203 | # SageMath parsed files 204 | *.sage.py 205 | 206 | # Spyder project settings 207 | .spyderproject 208 | .spyproject 209 | 210 | # Rope project settings 211 | .ropeproject 212 | 213 | # Mr Developer 214 | .mr.developer.cfg 215 | .project 216 | .pydevproject 217 | 218 | # mkdocs documentation 219 | /site 220 | 221 | # mypy 222 | .mypy_cache/ 223 | .dmypy.json 224 | dmypy.json 225 | 226 | # Pyre type checker 227 | .pyre/ 228 | 229 | # Plugins 230 | .secrets.baseline 231 | 232 | ### VisualStudioCode ### 233 | .vscode/* 234 | !.vscode/tasks.json 235 | !.vscode/launch.json 236 | !.vscode/extensions.json 237 | 238 | ### VisualStudioCode Patch ### 239 | # Ignore all local history of files 240 | .history 241 | 242 | ### Windows ### 243 | # Windows thumbnail cache files 244 | Thumbs.db 245 | Thumbs.db:encryptable 246 | ehthumbs.db 247 | ehthumbs_vista.db 248 | 249 | # Dump file 250 | *.stackdump 251 | 252 | # Folder config file 253 | [Dd]esktop.ini 254 | 255 | # Recycle Bin used on file shares 256 | $RECYCLE.BIN/ 257 | 258 | # Windows Installer files 259 | *.cab 260 | *.msi 261 | *.msix 262 | *.msm 263 | *.msp 264 | 265 | # Windows shortcuts 266 | *.lnk 267 | 268 | ### VisualStudio ### 269 | ## Ignore Visual Studio temporary files, build results, and 270 | ## files generated by popular Visual Studio add-ons. 271 | ## 272 | ## Get latest from https://github.com/github/gitignore/blob/master/VisualStudio.gitignore 273 | 274 | # User-specific files 275 | *.rsuser 276 | *.suo 277 | *.user 278 | *.userosscache 279 | *.sln.docstates 280 | 281 | # User-specific files (MonoDevelop/Xamarin Studio) 282 | *.userprefs 283 | 284 | # Mono auto generated files 285 | mono_crash.* 286 | 287 | # Build results 288 | [Dd]ebug/ 289 | [Dd]ebugPublic/ 290 | [Rr]elease/ 291 | [Rr]eleases/ 292 | x64/ 293 | x86/ 294 | [Aa][Rr][Mm]/ 295 | [Aa][Rr][Mm]64/ 296 | bld/ 297 | [Bb]in/ 298 | [Oo]bj/ 299 | [Ll]og/ 300 | 301 | # Visual Studio 2015/2017 cache/options directory 302 | .vs/ 303 | # Uncomment if you have tasks that create the project's static files in wwwroot 304 | #wwwroot/ 305 | 306 | # Visual Studio 2017 auto generated files 307 | Generated\ Files/ 308 | 309 | # MSTest test Results 310 | [Tt]est[Rr]esult*/ 311 | [Bb]uild[Ll]og.* 312 | 313 | # NUnit 314 | *.VisualState.xml 315 | TestResult.xml 316 | nunit-*.xml 317 | 318 | # Build Results of an ATL Project 319 | [Dd]ebugPS/ 320 | [Rr]eleasePS/ 321 | dlldata.c 322 | 323 | # Benchmark Results 324 | BenchmarkDotNet.Artifacts/ 325 | 326 | # .NET Core 327 | project.lock.json 328 | project.fragment.lock.json 329 | artifacts/ 330 | 331 | # StyleCop 332 | StyleCopReport.xml 333 | 334 | # Files built by Visual Studio 335 | *_i.c 336 | *_p.c 337 | *_h.h 338 | *.ilk 339 | *.obj 340 | *.iobj 341 | *.pch 342 | *.pdb 343 | *.ipdb 344 | *.pgc 345 | *.pgd 346 | *.rsp 347 | *.sbr 348 | *.tlb 349 | *.tli 350 | *.tlh 351 | *.tmp 352 | *.tmp_proj 353 | *_wpftmp.csproj 354 | *.log 355 | *.vspscc 356 | *.vssscc 357 | .builds 358 | *.pidb 359 | *.svclog 360 | *.scc 361 | 362 | # Chutzpah Test files 363 | _Chutzpah* 364 | 365 | # Visual C++ cache files 366 | ipch/ 367 | *.aps 368 | *.ncb 369 | *.opendb 370 | *.opensdf 371 | *.sdf 372 | *.cachefile 373 | *.VC.db 374 | *.VC.VC.opendb 375 | 376 | # Visual Studio profiler 377 | *.psess 378 | *.vsp 379 | *.vspx 380 | *.sap 381 | 382 | # Visual Studio Trace Files 383 | *.e2e 384 | 385 | # TFS 2012 Local Workspace 386 | $tf/ 387 | 388 | # Guidance Automation Toolkit 389 | *.gpState 390 | 391 | # ReSharper is a .NET coding add-in 392 | _ReSharper*/ 393 | *.[Rr]e[Ss]harper 394 | *.DotSettings.user 395 | 396 | # JustCode is a .NET coding add-in 397 | .JustCode 398 | 399 | # TeamCity is a build add-in 400 | _TeamCity* 401 | 402 | # DotCover is a Code Coverage Tool 403 | *.dotCover 404 | 405 | # AxoCover is a Code Coverage Tool 406 | .axoCover/* 407 | !.axoCover/settings.json 408 | 409 | # Visual Studio code coverage results 410 | *.coverage 411 | *.coveragexml 412 | 413 | # NCrunch 414 | _NCrunch_* 415 | .*crunch*.local.xml 416 | nCrunchTemp_* 417 | 418 | # MightyMoose 419 | *.mm.* 420 | AutoTest.Net/ 421 | 422 | # Web workbench (sass) 423 | .sass-cache/ 424 | 425 | # Installshield output folder 426 | [Ee]xpress/ 427 | 428 | # DocProject is a documentation generator add-in 429 | DocProject/buildhelp/ 430 | DocProject/Help/*.HxT 431 | DocProject/Help/*.HxC 432 | DocProject/Help/*.hhc 433 | DocProject/Help/*.hhk 434 | DocProject/Help/*.hhp 435 | DocProject/Help/Html2 436 | DocProject/Help/html 437 | 438 | # Click-Once directory 439 | publish/ 440 | 441 | # Publish Web Output 442 | *.[Pp]ublish.xml 443 | *.azurePubxml 444 | # Note: Comment the next line if you want to checkin your web deploy settings, 445 | # but database connection strings (with potential passwords) will be unencrypted 446 | *.pubxml 447 | *.publishproj 448 | 449 | # Microsoft Azure Web App publish settings. Comment the next line if you want to 450 | # checkin your Azure Web App publish settings, but sensitive information contained 451 | # in these scripts will be unencrypted 452 | PublishScripts/ 453 | 454 | # NuGet Packages 455 | *.nupkg 456 | # NuGet Symbol Packages 457 | *.snupkg 458 | # The packages folder can be ignored because of Package Restore 459 | **/[Pp]ackages/* 460 | # except build/, which is used as an MSBuild target. 461 | !**/[Pp]ackages/build/ 462 | # Uncomment if necessary however generally it will be regenerated when needed 463 | #!**/[Pp]ackages/repositories.config 464 | # NuGet v3's project.json files produces more ignorable files 465 | *.nuget.props 466 | *.nuget.targets 467 | 468 | # Microsoft Azure Build Output 469 | csx/ 470 | *.build.csdef 471 | 472 | # Microsoft Azure Emulator 473 | ecf/ 474 | rcf/ 475 | 476 | # Windows Store app package directories and files 477 | AppPackages/ 478 | BundleArtifacts/ 479 | Package.StoreAssociation.xml 480 | _pkginfo.txt 481 | *.appx 482 | *.appxbundle 483 | *.appxupload 484 | 485 | # Visual Studio cache files 486 | # files ending in .cache can be ignored 487 | *.[Cc]ache 488 | # but keep track of directories ending in .cache 489 | !?*.[Cc]ache/ 490 | 491 | # Others 492 | ClientBin/ 493 | ~$* 494 | *~ 495 | *.dbmdl 496 | *.dbproj.schemaview 497 | *.jfm 498 | *.pfx 499 | *.publishsettings 500 | orleans.codegen.cs 501 | 502 | # Including strong name files can present a security risk 503 | # (https://github.com/github/gitignore/pull/2483#issue-259490424) 504 | #*.snk 505 | 506 | # Since there are multiple workflows, uncomment next line to ignore bower_components 507 | # (https://github.com/github/gitignore/pull/1529#issuecomment-104372622) 508 | #bower_components/ 509 | 510 | # RIA/Silverlight projects 511 | Generated_Code/ 512 | 513 | # Backup & report files from converting an old project file 514 | # to a newer Visual Studio version. Backup files are not needed, 515 | # because we have git ;-) 516 | _UpgradeReport_Files/ 517 | Backup*/ 518 | UpgradeLog*.XML 519 | UpgradeLog*.htm 520 | ServiceFabricBackup/ 521 | *.rptproj.bak 522 | 523 | # SQL Server files 524 | *.mdf 525 | *.ldf 526 | *.ndf 527 | 528 | # Business Intelligence projects 529 | *.rdl.data 530 | *.bim.layout 531 | *.bim_*.settings 532 | *.rptproj.rsuser 533 | *- [Bb]ackup.rdl 534 | *- [Bb]ackup ([0-9]).rdl 535 | *- [Bb]ackup ([0-9][0-9]).rdl 536 | 537 | # Microsoft Fakes 538 | FakesAssemblies/ 539 | 540 | # GhostDoc plugin setting file 541 | *.GhostDoc.xml 542 | 543 | # Node.js Tools for Visual Studio 544 | .ntvs_analysis.dat 545 | node_modules/ 546 | 547 | # Visual Studio 6 build log 548 | *.plg 549 | 550 | # Visual Studio 6 workspace options file 551 | *.opt 552 | 553 | # Visual Studio 6 auto-generated workspace file (contains which files were open etc.) 554 | *.vbw 555 | 556 | # Visual Studio LightSwitch build output 557 | **/*.HTMLClient/GeneratedArtifacts 558 | **/*.DesktopClient/GeneratedArtifacts 559 | **/*.DesktopClient/ModelManifest.xml 560 | **/*.Server/GeneratedArtifacts 561 | **/*.Server/ModelManifest.xml 562 | _Pvt_Extensions 563 | 564 | # Paket dependency manager 565 | .paket/paket.exe 566 | paket-files/ 567 | 568 | # FAKE - F# Make 569 | .fake/ 570 | 571 | # CodeRush personal settings 572 | .cr/personal 573 | 574 | # Python Tools for Visual Studio (PTVS) 575 | *.pyc 576 | 577 | # Cake - Uncomment if you are using it 578 | # tools/** 579 | # !tools/packages.config 580 | 581 | # Tabs Studio 582 | *.tss 583 | 584 | # Telerik's JustMock configuration file 585 | *.jmconfig 586 | 587 | # BizTalk build output 588 | *.btp.cs 589 | *.btm.cs 590 | *.odx.cs 591 | *.xsd.cs 592 | 593 | # OpenCover UI analysis results 594 | OpenCover/ 595 | 596 | # Azure Stream Analytics local run output 597 | ASALocalRun/ 598 | 599 | # MSBuild Binary and Structured Log 600 | *.binlog 601 | 602 | # NVidia Nsight GPU debugger configuration file 603 | *.nvuser 604 | 605 | # MFractors (Xamarin productivity tool) working folder 606 | .mfractor/ 607 | 608 | # Local History for Visual Studio 609 | .localhistory/ 610 | 611 | # BeatPulse healthcheck temp database 612 | healthchecksdb 613 | 614 | # Backup folder for Package Reference Convert tool in Visual Studio 2017 615 | MigrationBackup/ 616 | 617 | # End of https://www.gitignore.io/api/osx,python,pycharm,windows,visualstudio,visualstudiocode 618 | --------------------------------------------------------------------------------