├── .gitmodules ├── MANIFEST.in ├── requirements ├── build.in ├── tests.in ├── typing.in ├── dev.in ├── docs.in ├── build.txt ├── tests.txt ├── typing.txt ├── docs.txt └── dev.txt ├── docs ├── changelog.rst ├── Makefile ├── conf.py └── index.rst ├── .readthedocs.yaml ├── .travis.yml ├── .editorconfig ├── .gitignore ├── .github ├── ISSUE_TEMPLATE │ ├── config.yml │ ├── feature-request.md │ └── bug-report.md ├── dependabot.yml ├── workflows │ ├── lock.yaml │ ├── tests.yaml │ └── publish.yaml └── pull_request_template.md ├── .pre-commit-config.yaml ├── README.md ├── LICENSE.txt ├── tox.ini ├── CHANGES ├── pyproject.toml ├── scripts └── release.py ├── src └── flask_principal.py └── tests └── test_principal.py /.gitmodules: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include LICENSE 2 | -------------------------------------------------------------------------------- /requirements/build.in: -------------------------------------------------------------------------------- 1 | build 2 | -------------------------------------------------------------------------------- /requirements/tests.in: -------------------------------------------------------------------------------- 1 | pytest 2 | -------------------------------------------------------------------------------- /docs/changelog.rst: -------------------------------------------------------------------------------- 1 | .. include:: ../CHANGES -------------------------------------------------------------------------------- /requirements/typing.in: -------------------------------------------------------------------------------- 1 | pyright 2 | pytest 3 | mypy -------------------------------------------------------------------------------- /requirements/dev.in: -------------------------------------------------------------------------------- 1 | -r docs.txt 2 | -r tests.txt 3 | pre-commit 4 | tox 5 | -------------------------------------------------------------------------------- /requirements/docs.in: -------------------------------------------------------------------------------- 1 | pallets-sphinx-themes 2 | sphinx 3 | sphinxcontrib-log-cabinet 4 | -------------------------------------------------------------------------------- /.readthedocs.yaml: -------------------------------------------------------------------------------- 1 | version: 2 2 | build: 3 | os: ubuntu-22.04 4 | tools: 5 | python: '3.12' 6 | python: 7 | install: 8 | - requirements: requirements/docs.txt 9 | - method: pip 10 | path: . 11 | sphinx: 12 | builder: dirhtml 13 | fail_on_warning: true -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: python 2 | 3 | python: 4 | - "2.6" 5 | - "2.7" 6 | - "3.3" 7 | - "3.4" 8 | - "pypy" 9 | 10 | install: 11 | - pip install . --quiet 12 | - pip install pytest nose --quiet 13 | 14 | script: nosetests 15 | 16 | branches: 17 | only: 18 | - master 19 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | indent_style = space 5 | indent_size = 4 6 | insert_final_newline = true 7 | trim_trailing_whitespace = true 8 | end_of_line = lf 9 | charset = utf-8 10 | max_line_length = 88 11 | 12 | [*.{css,html,js,json,jsx,scss,ts,tsx,yaml,yml}] 13 | indent_size = 2 -------------------------------------------------------------------------------- /requirements/build.txt: -------------------------------------------------------------------------------- 1 | # 2 | # This file is autogenerated by pip-compile with Python 3.12 3 | # by the following command: 4 | # 5 | # pip-compile build.in 6 | # 7 | build==1.2.2.post1 8 | # via -r build.in 9 | packaging==24.0 10 | # via build 11 | pyproject-hooks==1.1.0 12 | # via build 13 | -------------------------------------------------------------------------------- /requirements/tests.txt: -------------------------------------------------------------------------------- 1 | # 2 | # This file is autogenerated by pip-compile with Python 3.12 3 | # by the following command: 4 | # 5 | # pip-compile tests.in 6 | # 7 | iniconfig==2.0.0 8 | # via pytest 9 | packaging==24.0 10 | # via pytest 11 | pluggy==1.5.0 12 | # via pytest 13 | pytest==8.3.3 14 | # via -r tests.in 15 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.py[co] 2 | 3 | # Packages 4 | *.egg 5 | *.egg-info 6 | dist 7 | *build 8 | eggs 9 | parts 10 | bin 11 | var 12 | sdist 13 | develop-eggs 14 | .installed.cfg 15 | 16 | # Installer logs 17 | pip-log.txt 18 | 19 | # Unit test / coverage reports 20 | .coverage 21 | .tox 22 | 23 | #Translations 24 | *.mo 25 | 26 | #Mr Developer 27 | .mr.developer.cfg 28 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/config.yml: -------------------------------------------------------------------------------- 1 | blank_issues_enabled: false 2 | contact_links: 3 | - name: Questions on Discussions 4 | url: https://github.com/pallets-eco/flask-debugtoolbar/discussions/ 5 | about: Ask questions about your own code on the Discussions tab. 6 | - name: Questions on Chat 7 | url: https://discord.gg/pallets 8 | about: Ask questions about your own code on our Discord chat. 9 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: github-actions 4 | directory: / 5 | schedule: 6 | interval: monthly 7 | groups: 8 | github-actions: 9 | patterns: 10 | - '*' 11 | - package-ecosystem: pip 12 | directory: /requirements/ 13 | schedule: 14 | interval: monthly 15 | groups: 16 | python-requirements: 17 | patterns: 18 | - '*' 19 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature-request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest a new feature for Flask-Principal 4 | --- 5 | 6 | 10 | 11 | 16 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | ci: 2 | autoupdate_schedule: monthly 3 | repos: 4 | - repo: https://github.com/astral-sh/ruff-pre-commit 5 | rev: v0.4.2 6 | hooks: 7 | - id: ruff 8 | - id: ruff-format 9 | - repo: https://github.com/pre-commit/pre-commit-hooks 10 | rev: v4.6.0 11 | hooks: 12 | - id: check-merge-conflict 13 | exclude: "(codemirror|jquery)" 14 | - id: debug-statements 15 | exclude: "(codemirror|jquery)" 16 | - id: fix-byte-order-marker 17 | exclude: "(codemirror|jquery)" 18 | - id: trailing-whitespace 19 | exclude: "(codemirror|jquery)" 20 | - id: end-of-file-fixer 21 | exclude: "(codemirror|jquery)" -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ## Flask-Principal 2 | 3 | Identity management for Flask applications. This extension was originally 4 | written by Ali Afshar. Thanks to him for his great work. This is the new and 5 | official repository for this project. 6 | 7 | [Documentation](http://packages.python.org/Flask-Principal/) 8 | 9 | 10 | ## Pallets Community Ecosystem 11 | 12 | > [!IMPORTANT]\ 13 | > This project is part of the Pallets Community Ecosystem. Pallets is the open 14 | > source organization that maintains Flask; Pallets-Eco enables community 15 | > maintenance of related projects. If you are interested in helping maintain 16 | > this project, please reach out on [the Pallets Discord server][discord]. 17 | 18 | [discord]: https://discord.gg/pallets -------------------------------------------------------------------------------- /.github/workflows/lock.yaml: -------------------------------------------------------------------------------- 1 | name: Lock inactive closed issues 2 | # Lock closed issues that have not received any further activity for two weeks. 3 | # This does not close open issues, only humans may do that. It is easier to 4 | # respond to new issues with fresh examples rather than continuing discussions 5 | # on old issues. 6 | 7 | on: 8 | schedule: 9 | - cron: '0 0 * * *' 10 | permissions: 11 | issues: write 12 | pull-requests: write 13 | concurrency: 14 | group: lock 15 | jobs: 16 | lock: 17 | runs-on: ubuntu-latest 18 | steps: 19 | - uses: dessant/lock-threads@1bf7ec25051fe7c00bdd17e6a7cf3d7bfb7dc771 # v5.0.1 20 | with: 21 | issue-inactive-days: 14 22 | pr-inactive-days: 14 23 | discussion-inactive-days: 14 24 | -------------------------------------------------------------------------------- /requirements/typing.txt: -------------------------------------------------------------------------------- 1 | # 2 | # This file is autogenerated by pip-compile with Python 3.11 3 | # by the following command: 4 | # 5 | # pip-compile --output-file=requirements/typing.txt requirements/typing.in 6 | # 7 | iniconfig==2.0.0 8 | # via pytest 9 | mypy==1.13.0 10 | # via -r requirements/typing.in 11 | mypy-extensions==1.0.0 12 | # via mypy 13 | nodeenv==1.8.0 14 | # via pyright 15 | packaging==24.0 16 | # via pytest 17 | pluggy==1.5.0 18 | # via pytest 19 | pyright==1.1.387 20 | # via -r requirements/typing.in 21 | pytest==8.3.3 22 | # via -r requirements/typing.in 23 | typing-extensions==4.12.2 24 | # via 25 | # mypy 26 | # pyright 27 | 28 | # The following packages are considered to be unsafe in a requirements file: 29 | # setuptools 30 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug-report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Report a bug in Flask-Principal (not other projects which depend on Flask-Principal) 4 | --- 5 | 6 | 13 | 14 | 20 | 21 | 24 | 25 | Environment: 26 | 27 | - Python version: 28 | - Flask-Principal version: 29 | - Flask version: 30 | -------------------------------------------------------------------------------- /.github/pull_request_template.md: -------------------------------------------------------------------------------- 1 | 9 | 10 | 16 | 17 | 26 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | Copyright (c) 2012 Ali Afshar 2 | 3 | Permission is hereby granted, free of charge, to any person 4 | obtaining a copy of this software and associated documentation 5 | files (the "Software"), to deal in the Software without 6 | restriction, including without limitation the rights to use, 7 | copy, modify, merge, publish, distribute, sublicense, and/or sell 8 | copies of the Software, and to permit persons to whom the 9 | Software is furnished to do so, subject to the following 10 | conditions: 11 | 12 | The above copyright notice and this permission notice shall be 13 | included in all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 16 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES 17 | OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 18 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT 19 | HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, 20 | WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING 21 | FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR 22 | OTHER DEALINGS IN THE SOFTWARE. -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [tox] 2 | envlist = 3 | py3{12,11,10,9,8} 4 | minimal 5 | typing 6 | style 7 | docs 8 | skip_missing_interpreters = true 9 | 10 | [testenv] 11 | package = wheel 12 | wheel_build_env = .pkg 13 | constrain_package_deps = true 14 | use_frozen_constraints = true 15 | deps = -r requirements/tests.txt 16 | commands = pytest -v --tb=short --basetemp={envtmpdir} {posargs} 17 | 18 | [testenv:minimal] 19 | deps = 20 | commands = python -c "from flask_principal import Principal" 21 | 22 | [testenv:style] 23 | deps = pre-commit 24 | skip_install = true 25 | commands = pre-commit run --all-files 26 | 27 | [testenv:typing] 28 | deps = -r requirements/typing.txt 29 | commands = 30 | mypy src 31 | pyright src 32 | 33 | [testenv:docs] 34 | deps = -r requirements/docs.txt 35 | commands = sphinx-build -E -W -b dirhtml docs docs/_build/dirhtml 36 | 37 | [testenv:update-pre_commit] 38 | labels = update 39 | deps = pre-commit 40 | skip_install = true 41 | commands = pre-commit autoupdate -j4 42 | 43 | [testenv:update-requirements] 44 | base_python = 3.8 45 | labels = update 46 | deps = pip-tools 47 | skip_install = true 48 | change_dir = requirements 49 | commands = 50 | pip-compile build.in -q {posargs:-U} 51 | pip-compile docs.in -q {posargs:-U} 52 | pip-compile tests.in -q {posargs:-U} 53 | pip-compile dev.in -q {posargs:-U} -------------------------------------------------------------------------------- /requirements/docs.txt: -------------------------------------------------------------------------------- 1 | # 2 | # This file is autogenerated by pip-compile with Python 3.12 3 | # by the following command: 4 | # 5 | # pip-compile docs.in 6 | # 7 | alabaster==0.7.16 8 | # via sphinx 9 | babel==2.14.0 10 | # via sphinx 11 | certifi==2024.7.4 12 | # via requests 13 | charset-normalizer==3.3.2 14 | # via requests 15 | docutils==0.20.1 16 | # via sphinx 17 | idna==3.7 18 | # via requests 19 | imagesize==1.4.1 20 | # via sphinx 21 | jinja2==3.1.4 22 | # via sphinx 23 | markupsafe==2.1.5 24 | # via jinja2 25 | packaging==24.0 26 | # via 27 | # pallets-sphinx-themes 28 | # sphinx 29 | pallets-sphinx-themes==2.3.0 30 | # via -r docs.in 31 | pygments==2.17.2 32 | # via sphinx 33 | requests==2.32.0 34 | # via sphinx 35 | snowballstemmer==2.2.0 36 | # via sphinx 37 | sphinx==8.1.3 38 | # via 39 | # -r docs.in 40 | # pallets-sphinx-themes 41 | # sphinx-notfound-page 42 | # sphinxcontrib-log-cabinet 43 | sphinx-notfound-page==1.0.4 44 | # via pallets-sphinx-themes 45 | sphinxcontrib-applehelp==2.0.0 46 | # via sphinx 47 | sphinxcontrib-devhelp==2.0.0 48 | # via sphinx 49 | sphinxcontrib-htmlhelp==2.1.0 50 | # via sphinx 51 | sphinxcontrib-jsmath==1.0.1 52 | # via sphinx 53 | sphinxcontrib-log-cabinet==1.0.1 54 | # via -r docs.in 55 | sphinxcontrib-qthelp==2.0.0 56 | # via sphinx 57 | sphinxcontrib-serializinghtml==1.1.10 58 | # via sphinx 59 | urllib3==2.2.2 60 | # via requests 61 | -------------------------------------------------------------------------------- /CHANGES: -------------------------------------------------------------------------------- 1 | Flask-Principal Changelog 2 | ========================= 3 | 4 | Here you can see the full list of changes between each Flask-Principal release. 5 | 6 | Version 0.4.1 7 | ------------- 8 | 9 | Unreleased 10 | 11 | - Added Pallets projects scaffolding 12 | - Updated docstrings. Changed ``flask.ext.principal`` imports to ``flask_principal``. 13 | - Updated ``Permission`` needs and excludes to never conflict with each other. 14 | - Updated docs: Flask-login sections 15 | 16 | Version 0.4.0 17 | ------------- 18 | 19 | Released June 14th 2013 20 | 21 | - Added Python 3 support 22 | - Dropped support for Python 2.5 23 | 24 | Version 0.3.5 25 | ------------- 26 | 27 | Released April 3rd 2013 28 | 29 | - Fixed possible bug with ``AnonymousIdentity`` supplying "anon" as the username 30 | - Changed Indentity ``name`` property to ``id`` to be more generic 31 | 32 | 33 | Version 0.3.4 34 | ------------- 35 | 36 | Released February 1st 2013 37 | 38 | - Add ``__repr__`` method to Identity and Permission classes 39 | - Optimized ``_is_static_resource`` method 40 | 41 | 42 | Version 0.3.3 43 | ------------- 44 | 45 | Released September 4th 2012 46 | 47 | - Add ``init_app`` method to accomodate usage with a factory pattern. 48 | 49 | 50 | Version 0.3.2 51 | ------------- 52 | 53 | Released August 25th 2012 54 | 55 | - Update to check for ``static_url_path`` in Flask 0.9 56 | 57 | 58 | Version 0.3.1 59 | ------------- 60 | 61 | Released August 16th 2012 62 | 63 | - Fixed bug with re-raising exceptions/tracebacks 64 | 65 | 66 | Version 0.3 67 | ----------- 68 | 69 | Released June 20th 2012 70 | 71 | - Python 2.5/GAE support 72 | - New extension structure 73 | - Added ignore_static option 74 | - Updated docs 75 | 76 | 77 | Version 0.2 78 | ----------- 79 | 80 | Initial development by Ali Asfshar. `Original repository `_ 81 | -------------------------------------------------------------------------------- /.github/workflows/tests.yaml: -------------------------------------------------------------------------------- 1 | name: Tests 2 | on: 3 | push: 4 | branches: 5 | - main 6 | - '*.x' 7 | paths-ignore: 8 | - 'docs/**' 9 | - '*.md' 10 | - '*.rst' 11 | pull_request: 12 | paths-ignore: 13 | - 'docs/**' 14 | - '*.md' 15 | - '*.rst' 16 | jobs: 17 | tests: 18 | name: ${{ matrix.name || matrix.python }} 19 | runs-on: ${{ matrix.os || 'ubuntu-latest' }} 20 | strategy: 21 | fail-fast: false 22 | matrix: 23 | include: 24 | - {python: '3.12'} 25 | - {python: '3.11'} 26 | - {python: '3.10'} 27 | - {python: '3.9'} 28 | - {python: '3.8'} 29 | - {name: Minimal, python: '3.12', tox: minimal} 30 | steps: 31 | - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 32 | - uses: actions/setup-python@0b93645e9fea7318ecaed2b359559ac225c90a2b # v5.3.0 33 | with: 34 | python-version: ${{ matrix.python }} 35 | allow-prereleases: true 36 | cache: pip 37 | cache-dependency-path: requirements*/*.txt 38 | - run: pip install tox 39 | - run: tox run -e ${{ matrix.tox || format('py{0}', matrix.python) }} 40 | typing: 41 | runs-on: ubuntu-latest 42 | steps: 43 | - uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7 44 | - uses: actions/setup-python@f677139bbe7f9c59b41e40162b753c062f5d49a3 # v5.2.0 45 | with: 46 | python-version: '3.x' 47 | cache: pip 48 | cache-dependency-path: requirements*/*.txt 49 | - name: cache mypy 50 | uses: actions/cache@0c45773b623bea8c8e75f6c82b208c3cf94ea4f9 # v4.0.2 51 | with: 52 | path: ./.mypy_cache 53 | key: mypy|${{ hashFiles('pyproject.toml') }} 54 | - run: pip install tox 55 | - run: tox run -e typing -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [project] 2 | name = "Flask-Principal" 3 | version = "0.4.0" 4 | description = "Identity management for Flask." 5 | readme = "README.md" 6 | license = { file = "LICENSE.txt" } 7 | author = [{ name = "Ali Afshar" }] 8 | maintainers = [{ name = "Pallets Ecosystem", email = "contact@palletsprojects.com" }] 9 | classifiers = [ 10 | "Development Status :: 4 - Beta", 11 | "Framework :: Flask", 12 | "Programming Language :: Python", 13 | "Environment :: Web Environment", 14 | "Programming Language :: Python", 15 | "Topic :: Internet :: WWW/HTTP :: Dynamic Content", 16 | "Topic :: Software Development :: Libraries :: Python Modules" 17 | ] 18 | requires-python = ">=3.8" 19 | dependencies = [ 20 | "flask", 21 | ] 22 | 23 | [project.urls] 24 | Documentation = "https://github.com/pallets-eco/flask-principal" 25 | Changes = "https://github.com/pallets-eco/flask-principal/releases/" 26 | Source = "https://github.com/pallets-eco/flask-principal" 27 | Chat = "https://discord.gg/pallets" 28 | 29 | [build-system] 30 | requires = ["flit_core<4"] 31 | build-backend = "flit_core.buildapi" 32 | 33 | [tool.flit.module] 34 | name = "flask_principal" 35 | 36 | [tool.pytest.ini_options] 37 | testpaths = ["tests"] 38 | filterwarnings = [ 39 | "error", 40 | ] 41 | 42 | [tool.coverage.run] 43 | branch = true 44 | source = ["src", "tests"] 45 | 46 | [tool.coverage.paths] 47 | source = ["src", "*/site-packages"] 48 | 49 | [tool.pyright] 50 | pythonVersion = "3.8" 51 | include = ["src", "tests"] 52 | typeCheckingMode = "basic" 53 | 54 | [tool.ruff] 55 | src = ["src"] 56 | fix = true 57 | show-fixes = true 58 | output-format = "full" 59 | 60 | [tool.ruff.lint] 61 | select = [ 62 | "B", # flake8-bugbear 63 | "E", # pycodestyle error 64 | "F", # pyflakes 65 | "I", # isort 66 | "UP", # pyupgrade 67 | "W", # pycodestyle warning 68 | ] 69 | ignore-init-module-imports = true 70 | 71 | [tool.ruff.lint.isort] 72 | force-single-line = true 73 | order-by-type = false -------------------------------------------------------------------------------- /.github/workflows/publish.yaml: -------------------------------------------------------------------------------- 1 | name: Publish 2 | on: 3 | push: 4 | tags: 5 | - '*' 6 | jobs: 7 | build: 8 | runs-on: ubuntu-latest 9 | steps: 10 | - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 11 | - uses: actions/setup-python@0b93645e9fea7318ecaed2b359559ac225c90a2b # v5.3.0 12 | with: 13 | python-version: '3.x' 14 | cache: pip 15 | cache-dependency-path: requirements*/*.txt 16 | - run: pip install -r requirements/build.txt 17 | # Use the commit date instead of the current date during the build. 18 | - run: echo "SOURCE_DATE_EPOCH=$(git log -1 --pretty=%ct)" >> $GITHUB_ENV 19 | - run: python -m build 20 | - uses: actions/upload-artifact@b4b15b8c7c6ac21ea08fcf65892d2ee8f75cf882 # v4.4.3 21 | with: 22 | path: ./dist 23 | create-release: 24 | needs: [build] 25 | runs-on: ubuntu-latest 26 | permissions: 27 | contents: write 28 | steps: 29 | - uses: actions/download-artifact@fa0a91b85d4f404e444e00e005971372dc801d16 # v4.1.8 30 | - name: create release 31 | run: > 32 | gh release create --draft --repo ${{ github.repository }} 33 | ${{ github.ref_name }} artifact/* 34 | env: 35 | GH_TOKEN: ${{ github.token }} 36 | publish-pypi: 37 | needs: [build] 38 | # Wait for approval before attempting to upload to PyPI. This allows reviewing the 39 | # files in the draft release. 40 | environment: 41 | name: publish 42 | url: https://pypi.org/project/Flask-DebugToolbar/${{ github.ref_name }} 43 | runs-on: ubuntu-latest 44 | permissions: 45 | id-token: write 46 | steps: 47 | - uses: actions/download-artifact@fa0a91b85d4f404e444e00e005971372dc801d16 # v4.1.8 48 | - uses: pypa/gh-action-pypi-publish@fb13cb306901256ace3dab689990e13a5550ffaa # v1.11.0 49 | with: 50 | repository-url: https://test.pypi.org/legacy/ 51 | packages-dir: artifact/ 52 | - uses: pypa/gh-action-pypi-publish@fb13cb306901256ace3dab689990e13a5550ffaa # v1.11.0 53 | with: 54 | packages-dir: artifact/ 55 | -------------------------------------------------------------------------------- /requirements/dev.txt: -------------------------------------------------------------------------------- 1 | # 2 | # This file is autogenerated by pip-compile with Python 3.12 3 | # by the following command: 4 | # 5 | # pip-compile dev.in 6 | # 7 | alabaster==0.7.16 8 | # via 9 | # -r docs.txt 10 | # sphinx 11 | babel==2.14.0 12 | # via 13 | # -r docs.txt 14 | # sphinx 15 | cachetools==5.3.3 16 | # via tox 17 | certifi==2024.7.4 18 | # via 19 | # -r docs.txt 20 | # requests 21 | cfgv==3.4.0 22 | # via pre-commit 23 | chardet==5.2.0 24 | # via tox 25 | charset-normalizer==3.3.2 26 | # via 27 | # -r docs.txt 28 | # requests 29 | colorama==0.4.6 30 | # via tox 31 | distlib==0.3.8 32 | # via virtualenv 33 | docutils==0.20.1 34 | # via 35 | # -r docs.txt 36 | # sphinx 37 | filelock==3.14.0 38 | # via 39 | # tox 40 | # virtualenv 41 | identify==2.5.36 42 | # via pre-commit 43 | idna==3.7 44 | # via 45 | # -r docs.txt 46 | # requests 47 | imagesize==1.4.1 48 | # via 49 | # -r docs.txt 50 | # sphinx 51 | iniconfig==2.0.0 52 | # via 53 | # -r tests.txt 54 | # pytest 55 | jinja2==3.1.4 56 | # via 57 | # -r docs.txt 58 | # sphinx 59 | markupsafe==2.1.5 60 | # via 61 | # -r docs.txt 62 | # jinja2 63 | nodeenv==1.8.0 64 | # via pre-commit 65 | packaging==24.0 66 | # via 67 | # -r docs.txt 68 | # -r tests.txt 69 | # pallets-sphinx-themes 70 | # pyproject-api 71 | # pytest 72 | # sphinx 73 | # tox 74 | pallets-sphinx-themes==2.3.0 75 | # via -r docs.txt 76 | platformdirs==4.2.2 77 | # via 78 | # tox 79 | # virtualenv 80 | pluggy==1.5.0 81 | # via 82 | # -r tests.txt 83 | # pytest 84 | # tox 85 | pre-commit==4.0.1 86 | # via -r dev.in 87 | pygments==2.17.2 88 | # via 89 | # -r docs.txt 90 | # sphinx 91 | pyproject-api==1.6.1 92 | # via tox 93 | pytest==8.3.3 94 | # via -r tests.txt 95 | pyyaml==6.0.1 96 | # via pre-commit 97 | requests==2.32.0 98 | # via 99 | # -r docs.txt 100 | # sphinx 101 | snowballstemmer==2.2.0 102 | # via 103 | # -r docs.txt 104 | # sphinx 105 | sphinx==8.1.3 106 | # via 107 | # -r docs.txt 108 | # pallets-sphinx-themes 109 | # sphinx-notfound-page 110 | # sphinxcontrib-log-cabinet 111 | sphinx-notfound-page==1.0.4 112 | # via 113 | # -r docs.txt 114 | # pallets-sphinx-themes 115 | sphinxcontrib-applehelp==2.0.0 116 | # via 117 | # -r docs.txt 118 | # sphinx 119 | sphinxcontrib-devhelp==2.0.0 120 | # via 121 | # -r docs.txt 122 | # sphinx 123 | sphinxcontrib-htmlhelp==2.1.0 124 | # via 125 | # -r docs.txt 126 | # sphinx 127 | sphinxcontrib-jsmath==1.0.1 128 | # via 129 | # -r docs.txt 130 | # sphinx 131 | sphinxcontrib-log-cabinet==1.0.1 132 | # via -r docs.txt 133 | sphinxcontrib-qthelp==2.0.0 134 | # via 135 | # -r docs.txt 136 | # sphinx 137 | sphinxcontrib-serializinghtml==1.1.10 138 | # via 139 | # -r docs.txt 140 | # sphinx 141 | tox==4.15.1 142 | # via -r dev.in 143 | urllib3==2.2.2 144 | # via 145 | # -r docs.txt 146 | # requests 147 | virtualenv==20.26.2 148 | # via 149 | # pre-commit 150 | # tox 151 | 152 | # The following packages are considered to be unsafe in a requirements file: 153 | # setuptools 154 | -------------------------------------------------------------------------------- /docs/Makefile: -------------------------------------------------------------------------------- 1 | # Makefile for Sphinx documentation 2 | # 3 | 4 | # You can set these variables from the command line. 5 | SPHINXOPTS = 6 | SPHINXBUILD = sphinx-build 7 | PAPER = 8 | BUILDDIR = _build 9 | 10 | # Internal variables. 11 | PAPEROPT_a4 = -D latex_paper_size=a4 12 | PAPEROPT_letter = -D latex_paper_size=letter 13 | ALLSPHINXOPTS = -d $(BUILDDIR)/doctrees $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) . 14 | 15 | .PHONY: help clean html dirhtml singlehtml pickle json htmlhelp qthelp devhelp epub latex latexpdf text man changes linkcheck doctest 16 | 17 | help: 18 | @echo "Please use \`make ' where is one of" 19 | @echo " html to make standalone HTML files" 20 | @echo " dirhtml to make HTML files named index.html in directories" 21 | @echo " singlehtml to make a single large HTML file" 22 | @echo " pickle to make pickle files" 23 | @echo " json to make JSON files" 24 | @echo " htmlhelp to make HTML files and a HTML help project" 25 | @echo " qthelp to make HTML files and a qthelp project" 26 | @echo " devhelp to make HTML files and a Devhelp project" 27 | @echo " epub to make an epub" 28 | @echo " latex to make LaTeX files, you can set PAPER=a4 or PAPER=letter" 29 | @echo " latexpdf to make LaTeX files and run them through pdflatex" 30 | @echo " text to make text files" 31 | @echo " man to make manual pages" 32 | @echo " changes to make an overview of all changed/added/deprecated items" 33 | @echo " linkcheck to check all external links for integrity" 34 | @echo " doctest to run all doctests embedded in the documentation (if enabled)" 35 | 36 | clean: 37 | -rm -rf $(BUILDDIR)/* 38 | 39 | html: 40 | $(SPHINXBUILD) -b html $(ALLSPHINXOPTS) $(BUILDDIR)/html 41 | @echo 42 | @echo "Build finished. The HTML pages are in $(BUILDDIR)/html." 43 | 44 | dirhtml: 45 | $(SPHINXBUILD) -b dirhtml $(ALLSPHINXOPTS) $(BUILDDIR)/dirhtml 46 | @echo 47 | @echo "Build finished. The HTML pages are in $(BUILDDIR)/dirhtml." 48 | 49 | singlehtml: 50 | $(SPHINXBUILD) -b singlehtml $(ALLSPHINXOPTS) $(BUILDDIR)/singlehtml 51 | @echo 52 | @echo "Build finished. The HTML page is in $(BUILDDIR)/singlehtml." 53 | 54 | pickle: 55 | $(SPHINXBUILD) -b pickle $(ALLSPHINXOPTS) $(BUILDDIR)/pickle 56 | @echo 57 | @echo "Build finished; now you can process the pickle files." 58 | 59 | json: 60 | $(SPHINXBUILD) -b json $(ALLSPHINXOPTS) $(BUILDDIR)/json 61 | @echo 62 | @echo "Build finished; now you can process the JSON files." 63 | 64 | htmlhelp: 65 | $(SPHINXBUILD) -b htmlhelp $(ALLSPHINXOPTS) $(BUILDDIR)/htmlhelp 66 | @echo 67 | @echo "Build finished; now you can run HTML Help Workshop with the" \ 68 | ".hhp project file in $(BUILDDIR)/htmlhelp." 69 | 70 | qthelp: 71 | $(SPHINXBUILD) -b qthelp $(ALLSPHINXOPTS) $(BUILDDIR)/qthelp 72 | @echo 73 | @echo "Build finished; now you can run "qcollectiongenerator" with the" \ 74 | ".qhcp project file in $(BUILDDIR)/qthelp, like this:" 75 | @echo "# qcollectiongenerator $(BUILDDIR)/qthelp/FlaskPrincipal.qhcp" 76 | @echo "To view the help file:" 77 | @echo "# assistant -collectionFile $(BUILDDIR)/qthelp/FlaskPrincipal.qhc" 78 | 79 | devhelp: 80 | $(SPHINXBUILD) -b devhelp $(ALLSPHINXOPTS) $(BUILDDIR)/devhelp 81 | @echo 82 | @echo "Build finished." 83 | @echo "To view the help file:" 84 | @echo "# mkdir -p $$HOME/.local/share/devhelp/FlaskPrincipal" 85 | @echo "# ln -s $(BUILDDIR)/devhelp $$HOME/.local/share/devhelp/FlaskPrincipal" 86 | @echo "# devhelp" 87 | 88 | epub: 89 | $(SPHINXBUILD) -b epub $(ALLSPHINXOPTS) $(BUILDDIR)/epub 90 | @echo 91 | @echo "Build finished. The epub file is in $(BUILDDIR)/epub." 92 | 93 | latex: 94 | $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex 95 | @echo 96 | @echo "Build finished; the LaTeX files are in $(BUILDDIR)/latex." 97 | @echo "Run \`make all-pdf' or \`make all-ps' in that directory to" \ 98 | "run these through (pdf)latex." 99 | 100 | latexpdf: latex 101 | $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex 102 | @echo "Running LaTeX files through pdflatex..." 103 | make -C $(BUILDDIR)/latex all-pdf 104 | @echo "pdflatex finished; the PDF files are in $(BUILDDIR)/latex." 105 | 106 | text: 107 | $(SPHINXBUILD) -b text $(ALLSPHINXOPTS) $(BUILDDIR)/text 108 | @echo 109 | @echo "Build finished. The text files are in $(BUILDDIR)/text." 110 | 111 | man: 112 | $(SPHINXBUILD) -b man $(ALLSPHINXOPTS) $(BUILDDIR)/man 113 | @echo 114 | @echo "Build finished. The manual pages are in $(BUILDDIR)/man." 115 | 116 | changes: 117 | $(SPHINXBUILD) -b changes $(ALLSPHINXOPTS) $(BUILDDIR)/changes 118 | @echo 119 | @echo "The overview file is in $(BUILDDIR)/changes." 120 | 121 | linkcheck: 122 | $(SPHINXBUILD) -b linkcheck $(ALLSPHINXOPTS) $(BUILDDIR)/linkcheck 123 | @echo 124 | @echo "Link check complete; look for any errors in the above output " \ 125 | "or in $(BUILDDIR)/linkcheck/output.txt." 126 | 127 | doctest: 128 | $(SPHINXBUILD) -b doctest $(ALLSPHINXOPTS) $(BUILDDIR)/doctest 129 | @echo "Testing of doctests in the sources finished, look at the " \ 130 | "results in $(BUILDDIR)/doctest/output.txt." 131 | -------------------------------------------------------------------------------- /scripts/release.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | """ 4 | make-release 5 | ~~~~~~~~~~~~ 6 | 7 | Helper script that performs a release. Does pretty much everything 8 | automatically for us. 9 | 10 | :copyright: (c) 2011 by Armin Ronacher. 11 | :license: BSD, see LICENSE for more details. 12 | """ 13 | import sys 14 | import os 15 | import re 16 | from datetime import datetime, date 17 | from subprocess import Popen, PIPE 18 | 19 | _date_clean_re = re.compile(r'(\d+)(st|nd|rd|th)') 20 | 21 | 22 | def installed_libraries(): 23 | return Popen(['pip', 'freeze'], stdout=PIPE).communicate()[0] 24 | 25 | 26 | def has_library_installed(library): 27 | return library + '==' in installed_libraries() 28 | 29 | 30 | def parse_changelog(): 31 | with open('CHANGES') as f: 32 | lineiter = iter(f) 33 | for line in lineiter: 34 | match = re.search('^Version\s+(.*)', line.strip()) 35 | 36 | if match is None: 37 | continue 38 | 39 | version = match.group(1).strip() 40 | 41 | if lineiter.next().count('-') != len(line.strip()): 42 | fail('Invalid hyphen count below version line: %s', line.strip()) 43 | 44 | while 1: 45 | released = lineiter.next().strip() 46 | if released: 47 | break 48 | 49 | match = re.search(r'Released (\w+\s+\d+\w+\s+\d+)', released) 50 | 51 | if match is None: 52 | fail('Could not find release date in version %s' % version) 53 | 54 | datestr = parse_date(match.group(1).strip()) 55 | 56 | return version, datestr 57 | 58 | 59 | def bump_version(version): 60 | try: 61 | parts = map(int, version.split('.')) 62 | except ValueError: 63 | fail('Current version is not numeric') 64 | parts[-1] += 1 65 | return '.'.join(map(str, parts)) 66 | 67 | 68 | def parse_date(string): 69 | string = _date_clean_re.sub(r'\1', string) 70 | return datetime.strptime(string, '%B %d %Y') 71 | 72 | 73 | def set_filename_version(filename, version_number, pattern): 74 | changed = [] 75 | 76 | def inject_version(match): 77 | before, old, after = match.groups() 78 | changed.append(True) 79 | return before + version_number + after 80 | 81 | with open(filename) as f: 82 | contents = re.sub(r"^(\s*%s\s*=\s*')(.+?)(')(?sm)" % pattern, 83 | inject_version, f.read()) 84 | 85 | if not changed: 86 | fail('Could not find %s in %s', pattern, filename) 87 | 88 | with open(filename, 'w') as f: 89 | f.write(contents) 90 | 91 | 92 | def set_init_version(version): 93 | info('Setting __init__.py version to %s', version) 94 | set_filename_version('flask_principal/__init__.py', version, '__version__') 95 | 96 | 97 | def set_setup_version(version): 98 | info('Setting setup.py version to %s', version) 99 | set_filename_version('setup.py', version, 'version') 100 | 101 | 102 | def set_docs_version(version): 103 | info('Setting docs/conf.py version to %s', version) 104 | set_filename_version('docs/conf.py', version, 'version') 105 | 106 | 107 | def build_and_upload(): 108 | Popen([sys.executable, 'setup.py', 'sdist', 'build_sphinx', 'upload', 'upload_sphinx']).wait() 109 | 110 | 111 | def fail(message, *args): 112 | print >> sys.stderr, 'Error:', message % args 113 | sys.exit(1) 114 | 115 | 116 | def info(message, *args): 117 | print >> sys.stderr, message % args 118 | 119 | 120 | def get_git_tags(): 121 | return set(Popen(['git', 'tag'], stdout=PIPE).communicate()[0].splitlines()) 122 | 123 | 124 | def git_is_clean(): 125 | return Popen(['git', 'diff', '--quiet']).wait() == 0 126 | 127 | 128 | def make_git_commit(message, *args): 129 | message = message % args 130 | Popen(['git', 'commit', '-am', message]).wait() 131 | 132 | 133 | def make_git_tag(tag): 134 | info('Tagging "%s"', tag) 135 | Popen(['git', 'tag', '-a', tag, '-m', '%s release' % tag]).wait() 136 | Popen(['git', 'push', '--tags']).wait() 137 | 138 | 139 | def update_version(version): 140 | for f in [set_init_version, set_setup_version, set_docs_version]: 141 | f(version) 142 | 143 | 144 | def get_branches(): 145 | return set(Popen(['git', 'branch'], stdout=PIPE).communicate()[0].splitlines()) 146 | 147 | 148 | def branch_is(branch): 149 | return '* ' + branch in get_branches() 150 | 151 | 152 | def main(): 153 | os.chdir(os.path.join(os.path.dirname(__file__), '..')) 154 | 155 | rv = parse_changelog() 156 | 157 | if rv is None: 158 | fail('Could not parse changelog') 159 | 160 | version, release_date = rv 161 | 162 | tags = get_git_tags() 163 | 164 | for lib in ['Sphinx', 'Sphinx-PyPI-upload']: 165 | if not has_library_installed(lib): 166 | fail('Build requires that %s be installed', lib) 167 | 168 | if version in tags: 169 | fail('Version "%s" is already tagged', version) 170 | if release_date.date() != date.today(): 171 | fail('Release date is not today') 172 | 173 | if not branch_is('master'): 174 | fail('You are not on the master branch') 175 | 176 | if not git_is_clean(): 177 | fail('You have uncommitted changes in git') 178 | 179 | info('Releasing %s (release date %s)', 180 | version, release_date.strftime('%d/%m/%Y')) 181 | 182 | update_version(version) 183 | make_git_commit('Bump version number to %s', version) 184 | make_git_tag(version) 185 | build_and_upload() 186 | 187 | 188 | if __name__ == '__main__': 189 | main() 190 | -------------------------------------------------------------------------------- /docs/conf.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # 3 | # Flask Principal documentation build configuration file, created by 4 | # sphinx-quickstart on Sun Aug 1 19:01:12 2010. 5 | # 6 | # This file is execfile()d with the current directory set to its containing dir. 7 | # 8 | # Note that not all possible configuration values are present in this 9 | # autogenerated file. 10 | # 11 | # All configuration values have a default; values that are commented out 12 | # serve to show the default. 13 | 14 | import sys 15 | import os 16 | 17 | # If extensions (or modules to document with autodoc) are in another directory, 18 | # add these directories to sys.path here. If the directory is relative to the 19 | # documentation root, use os.path.abspath to make it absolute, like shown here. 20 | sys.path.insert(0, os.path.abspath('..')) 21 | sys.path.append(os.path.abspath('_themes')) 22 | 23 | # -- General configuration ----------------------------------------------------- 24 | 25 | # If your documentation needs a minimal Sphinx version, state it here. 26 | #needs_sphinx = '1.0' 27 | 28 | # Add any Sphinx extension module names here, as strings. They can be extensions 29 | # coming with Sphinx (named 'sphinx.ext.*') or your custom ones. 30 | extensions = ['sphinx.ext.autodoc', 31 | 'sphinx.ext.intersphinx', 32 | 'sphinx.ext.graphviz'] 33 | 34 | # Add any paths that contain templates here, relative to this directory. 35 | templates_path = ['_templates'] 36 | 37 | # The suffix of source filenames. 38 | source_suffix = '.rst' 39 | 40 | # The encoding of source files. 41 | #source_encoding = 'utf-8-sig' 42 | 43 | # The master toctree document. 44 | master_doc = 'index' 45 | 46 | # General information about the project. 47 | project = u'Flask Principal' 48 | copyright = u'2012, Ali Afshar' 49 | 50 | # The version info for the project you're documenting, acts as replacement for 51 | # |version| and |release|, also used in various other places throughout the 52 | # built documents. 53 | # 54 | # The short X.Y version. 55 | version = '0.3.5' 56 | # The full version, including alpha/beta/rc tags. 57 | release = version 58 | 59 | # The language for content autogenerated by Sphinx. Refer to documentation 60 | # for a list of supported languages. 61 | #language = None 62 | 63 | # There are two options for replacing |today|: either, you set today to some 64 | # non-false value, then it is used: 65 | #today = '' 66 | # Else, today_fmt is used as the format for a strftime call. 67 | #today_fmt = '%B %d, %Y' 68 | 69 | # List of patterns, relative to source directory, that match files and 70 | # directories to ignore when looking for source files. 71 | exclude_patterns = ['_build'] 72 | 73 | # The reST default role (used for this markup: `text`) to use for all documents. 74 | #default_role = None 75 | 76 | # If true, '()' will be appended to :func: etc. cross-reference text. 77 | #add_function_parentheses = True 78 | 79 | # If true, the current module name will be prepended to all description 80 | # unit titles (such as .. function::). 81 | #add_module_names = True 82 | 83 | # If true, sectionauthor and moduleauthor directives will be shown in the 84 | # output. They are ignored by default. 85 | #show_authors = False 86 | 87 | # The name of the Pygments (syntax highlighting) style to use. 88 | #pygments_style = 'sphinx' 89 | 90 | # A list of ignored prefixes for module index sorting. 91 | #modindex_common_prefix = [] 92 | 93 | 94 | # -- Options for HTML output --------------------------------------------------- 95 | 96 | # The theme to use for HTML and HTML Help pages. Major themes that come with 97 | # Sphinx are currently 'default' and 'sphinxdoc'. 98 | html_theme = 'flask_small' 99 | 100 | # Theme options are theme-specific and customize the look and feel of a theme 101 | # further. For a list of options available for each theme, see the 102 | # documentation. 103 | html_theme_options = { 104 | 'github_fork': 'pallets-eco/flask-principal', 105 | 'index_logo': False 106 | } 107 | 108 | # Add any paths that contain custom themes here, relative to this directory. 109 | html_theme_path = ['_themes'] 110 | 111 | # The name for this set of Sphinx documents. If None, it defaults to 112 | # " v documentation". 113 | #html_title = None 114 | 115 | # A shorter title for the navigation bar. Default is the same as html_title. 116 | #html_short_title = None 117 | 118 | # The name of an image file (relative to this directory) to place at the top 119 | # of the sidebar. 120 | #html_logo = None 121 | 122 | # The name of an image file (within the static path) to use as favicon of the 123 | # docs. This file should be a Windows icon file (.ico) being 16x16 or 32x32 124 | # pixels large. 125 | #html_favicon = None 126 | 127 | # Add any paths that contain custom static files (such as style sheets) here, 128 | # relative to this directory. They are copied after the builtin static files, 129 | # so a file named "default.css" will overwrite the builtin "default.css". 130 | html_static_path = ['_static'] 131 | 132 | # If not '', a 'Last updated on:' timestamp is inserted at every page bottom, 133 | # using the given strftime format. 134 | #html_last_updated_fmt = '%b %d, %Y' 135 | 136 | # If true, SmartyPants will be used to convert quotes and dashes to 137 | # typographically correct entities. 138 | #html_use_smartypants = True 139 | 140 | # Custom sidebar templates, maps document names to template names. 141 | #html_sidebars = {} 142 | 143 | # Additional templates that should be rendered to pages, maps page names to 144 | # template names. 145 | #html_additional_pages = {} 146 | 147 | # If false, no module index is generated. 148 | #html_domain_indices = True 149 | 150 | # If false, no index is generated. 151 | #html_use_index = True 152 | 153 | # If true, the index is split into individual pages for each letter. 154 | #html_split_index = False 155 | 156 | # If true, links to the reST sources are added to the pages. 157 | #html_show_sourcelink = True 158 | 159 | # If true, "Created using Sphinx" is shown in the HTML footer. Default is True. 160 | #html_show_sphinx = True 161 | 162 | # If true, "(C) Copyright ..." is shown in the HTML footer. Default is True. 163 | #html_show_copyright = True 164 | 165 | # If true, an OpenSearch description file will be output, and all pages will 166 | # contain a tag referring to it. The value of this option must be the 167 | # base URL from which the finished HTML is served. 168 | #html_use_opensearch = '' 169 | 170 | # If nonempty, this is the file name suffix for HTML files (e.g. ".xhtml"). 171 | #html_file_suffix = '' 172 | 173 | # Output file base name for HTML help builder. 174 | htmlhelp_basename = 'FlaskPrincipaldoc' 175 | 176 | 177 | # -- Options for LaTeX output -------------------------------------------------- 178 | 179 | latex_elements = { 180 | # The paper size ('letterpaper' or 'a4paper'). 181 | #'papersize': 'letterpaper', 182 | 183 | # The font size ('10pt', '11pt' or '12pt'). 184 | #'pointsize': '10pt', 185 | 186 | # Additional stuff for the LaTeX preamble. 187 | #'preamble': '', 188 | } 189 | 190 | # Grouping the document tree into LaTeX files. List of tuples 191 | # (source start file, target name, title, author, documentclass [howto/manual]). 192 | latex_documents = [ 193 | ('index', 'FlaskPrincipal.tex', u'Flask-Principal Documentation', 194 | u'Matt Wright', 'manual'), 195 | ] 196 | 197 | # The name of an image file (relative to this directory) to place at the top of 198 | # the title page. 199 | #latex_logo = None 200 | 201 | # For "manual" documents, if this is true, then toplevel headings are parts, 202 | # not chapters. 203 | #latex_use_parts = False 204 | 205 | # Additional stuff for the LaTeX preamble. 206 | #latex_preamble = '' 207 | 208 | # Documents to append as an appendix to all manuals. 209 | #latex_appendices = [] 210 | 211 | # If false, no module index is generated. 212 | #latex_domain_indices = True 213 | 214 | 215 | # -- Options for manual page output -------------------------------------------- 216 | 217 | # One entry per manual page. List of tuples 218 | # (source start file, name, description, authors, manual section). 219 | man_pages = [ 220 | ('index', 'flask-principal', u'Flask-Principal Documentation', 221 | [u'Matt Wright'], 1) 222 | ] 223 | 224 | # If true, show URL addresses after external links. 225 | #man_show_urls = False 226 | 227 | 228 | # -- Options for Texinfo output ------------------------------------------------ 229 | 230 | # Grouping the document tree into Texinfo files. List of tuples 231 | # (source start file, target name, title, author, 232 | # dir menu entry, description, category) 233 | texinfo_documents = [ 234 | ('index', 'Flask-Pecurity', u'Flask-Principal Documentation', 235 | u'Matt Wright', 'Flask-Principal', 'Identity management for Flask', 236 | 'Miscellaneous'), 237 | ] 238 | 239 | # Documents to append as an appendix to all manuals. 240 | #texinfo_appendices = [] 241 | 242 | # If false, no module index is generated. 243 | #texinfo_domain_indices = True 244 | 245 | # How to display URL addresses: 'footnote', 'no', or 'inline'. 246 | #texinfo_show_urls = 'footnote' 247 | 248 | 249 | # -- Options for Epub output --------------------------------------------------- 250 | 251 | # Bibliographic Dublin Core info. 252 | epub_title = u'Flask-Security' 253 | epub_author = u'Matt Wright' 254 | epub_publisher = u'Matt Wright' 255 | epub_copyright = u'2012, Matt Wright' 256 | 257 | # The language of the text. It defaults to the language option 258 | # or en if the language is not set. 259 | #epub_language = '' 260 | 261 | # The scheme of the identifier. Typical schemes are ISBN or URL. 262 | #epub_scheme = '' 263 | 264 | # The unique identifier of the text. This can be a ISBN number 265 | # or the project homepage. 266 | #epub_identifier = '' 267 | 268 | # A unique identification for the text. 269 | #epub_uid = '' 270 | 271 | # A tuple containing the cover image and cover page html template filenames. 272 | #epub_cover = () 273 | 274 | # HTML files that should be inserted before the pages created by sphinx. 275 | # The format is a list of tuples containing the path and title. 276 | #epub_pre_files = [] 277 | 278 | # HTML files shat should be inserted after the pages created by sphinx. 279 | # The format is a list of tuples containing the path and title. 280 | #epub_post_files = [] 281 | 282 | # A list of files that should not be packed into the epub file. 283 | #epub_exclude_files = [] 284 | 285 | # The depth of the table of contents in toc.ncx. 286 | #epub_tocdepth = 3 287 | 288 | # Allow duplicate toc entries. 289 | #epub_tocdup = True 290 | 291 | 292 | # Example configuration for intersphinx: refer to the Python standard library. 293 | intersphinx_mapping = {'python': ('http://docs.python.org/', None)} 294 | 295 | pygments_style = 'flask_theme_support.FlaskyStyle' 296 | 297 | # fall back if theme is not there 298 | try: 299 | __import__('flask_theme_support') 300 | except ImportError as e: 301 | print('-' * 74) 302 | print('Warning: Flask themes unavailable. Building with default theme') 303 | print('If you want the Flask themes, run this command and build again:') 304 | print() 305 | print(' git submodule update --init') 306 | print('-' * 74) 307 | 308 | pygments_style = 'tango' 309 | html_theme = 'default' 310 | html_theme_options = {} 311 | -------------------------------------------------------------------------------- /docs/index.rst: -------------------------------------------------------------------------------- 1 | 2 | Flask Principal 3 | =============== 4 | 5 | *"I am that I am"* 6 | 7 | 8 | Introduction 9 | ------------ 10 | 11 | Flask-Principal provides a very loose framework to tie in providers of two 12 | types of service, often located in different parts of a web application: 13 | 14 | 1. Authentication providers 15 | 2. User information providers 16 | 17 | For example, an authentication provider may be oauth, using Flask-OAuth and 18 | the user information may be stored in a relational database. Looseness of 19 | the framework is provided by using signals as the interface. 20 | 21 | The major components are the Identity, Needs, Permission, and the IdentityContext. 22 | 23 | 1. The Identity represents the user, and is stored/loaded from various 24 | locations (eg session) for each request. The Identity is the user's 25 | avatar to the system. It contains the access rights that the user has. 26 | 27 | 2. A Need is the smallest grain of access control, and represents a specific 28 | parameter for the situation. For example "has the admin role", "can edit 29 | blog posts". 30 | 31 | Needs are any tuple, or probably could be object you like, but a tuple 32 | fits perfectly. The predesigned Need types (for saving your typing) are 33 | either pairs of (method, value) where method is used to specify 34 | common things such as `"role"`, `"user"`, etc. And the value is the 35 | value. An example of such is `('role', 'admin')`. Which would be a 36 | Need for an admin role. Or Triples for use-cases such as "The permission 37 | to edit a particular instance of an object or row", which might be represented 38 | as the triple `('article', 'edit', 46)`, where 46 is the key/ID for that 39 | row/object. 40 | 41 | Essentially, how and what Needs are is very much down to the user, and is 42 | designed loosely so that any effect can be achieved by using custom 43 | instances as Needs. 44 | 45 | Whilst a Need is a permission to access a resource, an Identity should 46 | provide a set of Needs that it has access to. 47 | 48 | 3. A Permission is a set of requirements, any of which should be 49 | present for access to a resource. 50 | 51 | 4. An IdentityContext is the context of a certain identity against a certain 52 | Permission. It can be used as a context manager, or a decorator. 53 | 54 | 55 | .. graphviz:: 56 | 57 | 58 | digraph g { 59 | rankdir="LR" ; 60 | node [ colorscheme="pastel19" ]; 61 | fixedsize = "true" ; 62 | i [label="Identity", shape="circle" style="filled" width="1.5", fillcolor="1"] ; 63 | p [label="Permission", shape="circle" style="filled" width="1.5" fillcolor="2"] ; 64 | n [label=" Needs|{RoleNeed|ActionNeed}", shape="Mrecord" style="filled" fillcolor="3"] ; 65 | c [label="IdentityContext", shape="box" style="filled,rounded" fillcolor="4"] ; 66 | p -> n:all ; 67 | c -> i ; 68 | c -> p ; 69 | i -> n:n1 ; 70 | i -> n:n2 ; 71 | 72 | } 73 | 74 | 75 | 76 | Links 77 | ----- 78 | 79 | * `source `_ 80 | * :doc:`changelog ` 81 | 82 | Protecting access to resources 83 | ------------------------------ 84 | 85 | For users of Flask-Principal (not authentication providers), access 86 | restriction is easy to define as both a decorator and a context manager. A 87 | simple quickstart example is presented with commenting:: 88 | 89 | from flask import Flask, Response 90 | from flask_principal import Principal, Permission, RoleNeed 91 | 92 | app = Flask(__name__) 93 | 94 | # load the extension 95 | principals = Principal(app) 96 | 97 | # Create a permission with a single Need, in this case a RoleNeed. 98 | admin_permission = Permission(RoleNeed('admin')) 99 | 100 | # protect a view with a principal for that need 101 | @app.route('/admin') 102 | @admin_permission.require() 103 | def do_admin_index(): 104 | return Response('Only if you are an admin') 105 | 106 | # this time protect with a context manager 107 | @app.route('/articles') 108 | def do_articles(): 109 | with admin_permission.require(): 110 | return Response('Only if you are admin') 111 | 112 | Authentication providers 113 | ------------------------ 114 | 115 | Authentication providers should use the `identity-changed` signal to indicate 116 | that a request has been authenticated. For example, the following code is a 117 | hypothetical example of how one might combine the popular 118 | `Flask-Login `_ extension with 119 | Flask-Principal:: 120 | 121 | 122 | from flask import Flask, current_app, request, session, \ 123 | render_template, redirect 124 | from flask_login import LoginManager, login_user, logout_user, \ 125 | login_required 126 | from wtforms import StringField, PasswordField 127 | from flask_wtf import FlaskForm 128 | from flask_principal import Principal, Identity, AnonymousIdentity, \ 129 | identity_changed 130 | 131 | app = Flask(__name__) 132 | app.config['SECRET_KEY'] = 'notsosecret' 133 | 134 | Principal(app) 135 | 136 | login_manager = LoginManager(app) 137 | 138 | class User: 139 | def __init__(self, id='1', email=None, password=None): 140 | self.id = id 141 | self.email = email 142 | self.password = 'abcd' 143 | self.is_authenticated = False 144 | self.is_active = True 145 | self.is_anonymous = True 146 | 147 | def get_id(self): 148 | return self.id 149 | class DB: 150 | 151 | def __init__(self): 152 | pass 153 | 154 | def find_user(self, id=None, email=None): 155 | example_user = User(id=id, email=email) 156 | return example_user 157 | 158 | 159 | @login_manager.user_loader 160 | def load_user(userid): 161 | # Return an instance of the User model 162 | datastore = DB() 163 | return datastore.find_user(id=userid) 164 | 165 | class LoginForm(FlaskForm): 166 | email = StringField('email') 167 | password = PasswordField('password') 168 | 169 | @app.route('/login', methods=['GET', 'POST']) 170 | def login(): 171 | # A hypothetical login form that uses Flask-WTF 172 | form = LoginForm() 173 | 174 | if request.method == 'POST': 175 | 176 | # Validate form input 177 | if form.validate_on_submit(): 178 | # Retrieve the user from the hypothetical datastore 179 | datastore = DB() 180 | user = datastore.find_user(email=form.email.data) 181 | 182 | # Compare passwords (use password hashing production) 183 | if form.password.data == user.password: 184 | # Keep the user info in the session using Flask-Login 185 | login_user(user) 186 | 187 | # Tell Flask-Principal the identity changed 188 | identity_changed.send(current_app._get_current_object(), 189 | identity=Identity(user.id)) 190 | print('logged in ') 191 | return redirect(request.args.get('next') or '/') 192 | 193 | return render_template('login.html', form=form) 194 | 195 | @app.route('/logout') 196 | @login_required 197 | def logout(): 198 | # Remove the user information from the session 199 | logout_user() 200 | 201 | # Remove session keys set by Flask-Principal 202 | for key in ('identity.name', 'identity.auth_type'): 203 | session.pop(key, None) 204 | 205 | # Tell Flask-Principal the user is anonymous 206 | identity_changed.send(current_app._get_current_object(), 207 | identity=AnonymousIdentity()) 208 | 209 | return redirect(request.args.get('next') or '/') 210 | 211 | @app.route('/') 212 | def home(): 213 | return 'home page' 214 | if __name__ == '__main__': 215 | app.run(debug=True) 216 | 217 | The login page snippets looks like this: 218 | 219 | 220 | 221 |
222 | {{ form.csrf_token }} 223 | {{ form.email.label }} {{ form.email }}
224 | {{ form.csrf_token }} 225 | {{ form.password.label }} {{ form.password }}
226 | 227 |
228 | 229 | 230 | 231 | User Information providers 232 | -------------------------- 233 | 234 | User information providers should connect to the `identity-loaded` signal to 235 | add any additional information to the Identity instance such as roles. The 236 | following is another hypothetical example using Flask-Login and could be 237 | combined with the previous example. It shows how one might use a role based 238 | permission scheme:: 239 | 240 | from flask_login import current_user 241 | from flask_principal import identity_loaded, RoleNeed, UserNeed 242 | 243 | @identity_loaded.connect_via(app) 244 | def on_identity_loaded(sender, identity): 245 | # Set the identity user object 246 | identity.user = current_user 247 | 248 | # Add the UserNeed to the identity 249 | if hasattr(current_user, 'id'): 250 | identity.provides.add(UserNeed(current_user.id)) 251 | 252 | # Assuming the User model has a list of roles, update the 253 | # identity with the roles that the user provides 254 | if hasattr(current_user, 'roles'): 255 | for role in current_user.roles: 256 | identity.provides.add(RoleNeed(role.name)) 257 | 258 | 259 | Granular Resource Protection 260 | ---------------------------- 261 | 262 | Now lets say, for example, you only want the author of a blog post to be able to 263 | edit said article. This can be achieved by creating the necessary `Need` and 264 | `Permission` objects, and adding more logic into the `identity_loaded` signal 265 | handler. For example:: 266 | 267 | from collections import namedtuple 268 | from functools import partial 269 | 270 | from flask_login import current_user 271 | from flask_principal import identity_loaded, Permission, RoleNeed, \ 272 | UserNeed 273 | 274 | BlogPostNeed = namedtuple('blog_post', ['method', 'value']) 275 | EditBlogPostNeed = partial(BlogPostNeed, 'edit') 276 | 277 | class EditBlogPostPermission(Permission): 278 | def __init__(self, post_id): 279 | need = EditBlogPostNeed(unicode(post_id)) 280 | super(EditBlogPostPermission, self).__init__(need) 281 | 282 | @identity_loaded.connect_via(app) 283 | def on_identity_loaded(sender, identity): 284 | # Set the identity user object 285 | identity.user = current_user 286 | 287 | # Add the UserNeed to the identity 288 | if hasattr(current_user, 'id'): 289 | identity.provides.add(UserNeed(current_user.id)) 290 | 291 | # Assuming the User model has a list of roles, update the 292 | # identity with the roles that the user provides 293 | if hasattr(current_user, 'roles'): 294 | for role in current_user.roles: 295 | identity.provides.add(RoleNeed(role.name)) 296 | 297 | # Assuming the User model has a list of posts the user 298 | # has authored, add the needs to the identity 299 | if hasattr(current_user, 'posts'): 300 | for post in current_user.posts: 301 | identity.provides.add(EditBlogPostNeed(unicode(post.id))) 302 | 303 | The next step will be to protect the endpoint that allows a user to edit an 304 | article. This is done by creating a permission object on the fly using the ID 305 | of the resource, in this case the blog post:: 306 | 307 | @app.route('/posts/', methods=['PUT', 'PATCH']) 308 | def edit_post(post_id): 309 | permission = EditBlogPostPermission(post_id) 310 | 311 | if permission.can(): 312 | # Save the edits ... 313 | return render_template('edit_post.html') 314 | 315 | abort(403) # HTTP Forbidden 316 | 317 | Combining Permissions 318 | --------------------- 319 | 320 | While the core constructs provided are sufficient for the common simple use 321 | cases, for more complex cases it is possible to combine existing permissions 322 | through the use of bitwise operators to result in a new permission object that 323 | can do the complex validations. For instance, it is to create a permission 324 | where the identity has the both the `"blog_poster"` or `"blog_reviewer"` role 325 | but not the `"under_probation"` role. Example:: 326 | 327 | blog_admin = Permission(RoleNeed('blog_admin')) 328 | blog_poster = Permission(RoleNeed('blog_poster')) 329 | blog_reviewer = Permission(RoleNeed('blog_reviewer')) 330 | under_probation = Permission(RoleNeed('under_probation')) 331 | 332 | prize_permission = ((blog_poster | blog_reviewer) & ~under_probation) 333 | 334 | @app.route('/blog/prizes') 335 | @prize_permission.require() 336 | def prize_redeem(): 337 | # find out what prizes are available to blog users that are not 338 | # under probation 339 | return render_template('prize_redeem.html') 340 | 341 | Any number of these can be chained, but it is also possible to use the 342 | constructor classes directly to combine these things together:: 343 | 344 | from flask.ext.principal import AndPermission, OrPermission 345 | 346 | allperms = AndPermission(blog_poster, blog_reviewer, blog_admin) 347 | anyperms = OrPermission(blog_poster, blog_reviewer, blog_admin) 348 | 349 | Custom Permissions 350 | ------------------ 351 | 352 | Sometimes your permissions may be determined by other circumstances specific to 353 | whatever you need to implement. For that you can create custom classes to 354 | address your needs like so:: 355 | 356 | from flask.ext.principal import BasePermission 357 | 358 | class CustomPermission(BasePermission): 359 | def allows(self, identity): 360 | # Implement other conditions that allow this to pass 361 | return False 362 | 363 | These custom permissions can be combined together via the bitwise operators as 364 | explained above. 365 | 366 | API 367 | === 368 | 369 | 370 | 371 | Starting the extension 372 | ---------------------- 373 | 374 | .. autoclass:: flask_principal.Principal 375 | :members: 376 | 377 | 378 | Main Types 379 | ---------- 380 | 381 | .. autoclass:: flask_principal.Permission 382 | :members: 383 | 384 | .. autoclass:: flask_principal.Identity 385 | :members: 386 | 387 | .. autoclass:: flask_principal.AnonymousIdentity 388 | :members: 389 | 390 | .. autoclass:: flask_principal.IdentityContext 391 | :members: 392 | 393 | 394 | 395 | Predefined Need Types 396 | --------------------- 397 | 398 | .. autoclass:: flask_principal.Need 399 | 400 | .. autoclass:: flask_principal.RoleNeed 401 | 402 | .. autoclass:: flask_principal.UserNeed 403 | 404 | .. autoclass:: flask_principal.ItemNeed 405 | 406 | 407 | Signals 408 | ---------------- 409 | 410 | .. data:: identity_changed 411 | 412 | Signal sent when the identity for a request has been changed. 413 | 414 | .. data:: identity_loaded 415 | 416 | Signal sent when the identity has been initialised for a request. 417 | 418 | .. _Flask documentation on signals: http://flask.pocoo.org/docs/signals/ 419 | 420 | 421 | Changelog 422 | ========= 423 | .. toctree:: 424 | :maxdepth: 2 425 | 426 | changelog 427 | 428 | 429 | Indices and tables 430 | ================== 431 | 432 | * :ref:`genindex` 433 | * :ref:`modindex` 434 | * :ref:`search` 435 | 436 | -------------------------------------------------------------------------------- /src/flask_principal.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | """ 3 | flask_principal 4 | ~~~~~~~~~~~~~~~ 5 | 6 | Identity management for Flask. 7 | 8 | :copyright: (c) 2012 by Ali Afshar. 9 | :license: MIT, see LICENSE for more details. 10 | 11 | """ 12 | 13 | from __future__ import with_statement 14 | 15 | __version__ = '0.4.0' 16 | 17 | import sys 18 | 19 | from functools import partial, wraps 20 | from collections import deque 21 | from typing import cast, Any, Callable, Deque, Dict, Optional, Set, Tuple, TypeVar, Union, cast 22 | from collections import namedtuple 23 | 24 | from flask import g, session, current_app, abort, request 25 | from blinker.base import Namespace 26 | from flask import Flask 27 | 28 | PY3 = sys.version_info[0] == 3 29 | 30 | signals = Namespace() 31 | 32 | 33 | identity_changed = signals.signal('identity-changed', doc=""" 34 | Signal sent when the identity for a request has been changed. 35 | 36 | Actual name: ``identity-changed`` 37 | 38 | Authentication providers should send this signal when authentication has been 39 | successfully performed. Flask-Principal connects to this signal and 40 | causes the identity to be saved in the session. 41 | 42 | For example:: 43 | 44 | from flask_principal import Identity, identity_changed 45 | 46 | def login_view(req): 47 | username = req.form.get('username') 48 | # check the credentials 49 | identity_changed.send(app, identity=Identity(username)) 50 | """) 51 | 52 | 53 | identity_loaded = signals.signal('identity-loaded', doc=""" 54 | Signal sent when the identity has been initialised for a request. 55 | 56 | Actual name: ``identity-loaded`` 57 | 58 | Identity information providers should connect to this signal to perform two 59 | major activities: 60 | 61 | 1. Populate the identity object with the necessary authorization provisions. 62 | 2. Load any additional user information. 63 | 64 | For example:: 65 | 66 | from flask_principal import identity_loaded, RoleNeed, UserNeed 67 | 68 | @identity_loaded.connect 69 | def on_identity_loaded(sender, identity): 70 | # Get the user information from the db 71 | user = db.get(identity.name) 72 | # Update the roles that a user can provide 73 | for role in user.roles: 74 | identity.provides.add(RoleNeed(role.name)) 75 | # Save the user somewhere so we only look it up once 76 | identity.user = user 77 | """) 78 | 79 | 80 | Need = namedtuple('Need', ['method', 'value']) 81 | """A required need 82 | 83 | This is just a named tuple, and practically any tuple will do. 84 | 85 | The ``method`` attribute can be used to look up element 0, and the ``value`` 86 | attribute can be used to look up element 1. 87 | """ 88 | 89 | 90 | UserNeed = partial(Need, 'id') 91 | UserNeed.__doc__ = """A need with the method preset to `"id"`.""" 92 | 93 | 94 | RoleNeed = partial(Need, 'role') 95 | RoleNeed.__doc__ = """A need with the method preset to `"role"`.""" 96 | 97 | 98 | TypeNeed = partial(Need, 'type') 99 | TypeNeed.__doc__ = """A need with the method preset to `"type"`.""" 100 | 101 | 102 | ActionNeed = partial(Need, 'action') 103 | ActionNeed.__doc__ = """A need with the method preset to `"action"`.""" 104 | 105 | 106 | ItemNeed = namedtuple('ItemNeed', ['method', 'value', 'type']) 107 | """A required item need 108 | 109 | An item need is just a named tuple, and practically any tuple will do. In 110 | addition to other Needs, there is a type, for example this could be specified 111 | as:: 112 | 113 | ItemNeed('update', 27, 'posts') 114 | ('update', 27, 'posts') # or like this 115 | 116 | And that might describe the permission to update a particular blog post. In 117 | reality, the developer is free to choose whatever convention the permissions 118 | are. 119 | """ 120 | 121 | 122 | class PermissionDenied(RuntimeError): 123 | """Permission denied to the resource""" 124 | 125 | 126 | class IdentityContext(object): 127 | """The context of an identity for a permission. 128 | 129 | .. note:: The principal is usually created by the flask_principal.Permission.require method 130 | call for normal use-cases. 131 | 132 | The principal behaves as either a context manager or a decorator. The 133 | permission is checked for provision in the identity, and if available the 134 | flow is continued (context manager) or the function is executed (decorator). 135 | """ 136 | 137 | def __init__(self, permission: 'BasePermission', http_exception: Optional[int] = None) -> None: 138 | self.permission = permission 139 | self.http_exception = http_exception 140 | """The permission of this principal 141 | """ 142 | 143 | @property 144 | def identity(self) -> 'Identity': 145 | """The identity of this principal 146 | """ 147 | return cast(Identity, g.identity) 148 | 149 | def can(self) -> bool: 150 | """Whether the identity has access to the permission 151 | """ 152 | return self.identity.can(self.permission) 153 | 154 | def __call__(self, f: Callable[..., Any]) -> Callable[..., Any]: 155 | @wraps(f) 156 | def _decorated(*args: Any, **kw: Any) -> Any: 157 | with self: 158 | rv = f(*args, **kw) 159 | return rv 160 | return _decorated 161 | 162 | def __enter__(self) -> None: 163 | # check the permission here 164 | if not self.can(): 165 | if self.http_exception: 166 | abort(self.http_exception, self.permission) 167 | raise PermissionDenied(self.permission) 168 | 169 | def __exit__(self, *args: Any) -> None: 170 | pass 171 | 172 | 173 | class BasePermission: 174 | """The Base Permission.""" 175 | 176 | http_exception = None 177 | 178 | def _bool(self) -> bool: 179 | return bool(self.can()) 180 | 181 | def __nonzero__(self) -> bool: 182 | """Equivalent to ``self.can()``. 183 | """ 184 | return self._bool() 185 | 186 | def __bool__(self) -> bool: 187 | """Equivalent to ``self.can()``. 188 | """ 189 | return self._bool() 190 | 191 | def __or__(self, other: Union['Permission', 'BasePermission']) -> Union['Permission', 'BasePermission']: 192 | """See ``OrPermission``. 193 | """ 194 | return self.or_(other) 195 | 196 | def or_(self, other: Union['Permission', 'BasePermission']) -> Union['Permission', 'BasePermission']: 197 | return OrPermission(self, other) 198 | 199 | def __and__(self, other: Union['Permission', 'BasePermission']) -> Union['Permission', 'BasePermission']: 200 | """See ``AndPermission``. 201 | """ 202 | return self.and_(other) 203 | 204 | def and_(self, other: Union['Permission', 'BasePermission']) -> Union['Permission', 'BasePermission']: 205 | return AndPermission(self, other) 206 | 207 | def __invert__(self) -> Union['NotPermission', 'BasePermission']: 208 | """See ``NotPermission``. 209 | """ 210 | return self.invert() 211 | 212 | def invert(self) -> Union['NotPermission', 'BasePermission']: 213 | return NotPermission(self) 214 | 215 | def require(self, http_exception: Optional[int] = None) -> IdentityContext: 216 | """Create a principal for this permission. 217 | 218 | The principal may be used as a context manager, or a decroator. 219 | 220 | If ``http_exception`` is passed then ``abort()`` will be called 221 | with the HTTP exception code. Otherwise a ``PermissionDenied`` 222 | exception will be raised if the identity does not meet the 223 | requirements. 224 | 225 | :param http_exception: the HTTP exception code (403, 401 etc) 226 | """ 227 | 228 | if http_exception is None: 229 | http_exception = self.http_exception 230 | 231 | return IdentityContext(self, http_exception) 232 | 233 | def test(self, http_exception: Optional[int] = None) -> None: 234 | """ 235 | Checks if permission available and raises relevant exception 236 | if not. This is useful if you just want to check permission 237 | without wrapping everything in a require() block. 238 | 239 | This is equivalent to:: 240 | 241 | with permission.require(): 242 | pass 243 | """ 244 | 245 | with self.require(http_exception): 246 | pass 247 | 248 | def allows(self, identity: 'Identity') -> bool: 249 | """Whether the identity can access this permission. 250 | 251 | :param identity: The identity 252 | """ 253 | 254 | raise NotImplementedError 255 | 256 | def can(self) -> bool: 257 | """Whether the required context for this permission has access 258 | 259 | This creates an identity context and tests whether it can access this 260 | permission 261 | """ 262 | return self.require().can() 263 | 264 | class Identity: 265 | """Represent the user's identity. 266 | 267 | :param id: The user id 268 | :param auth_type: The authentication type used to confirm the user's 269 | identity. 270 | 271 | The identity is used to represent the user's identity in the system. This 272 | object is created on login, or on the start of the request as loaded from 273 | the user's session. 274 | 275 | Once loaded it is sent using the `identity-loaded` signal, and should be 276 | populated with additional required information. 277 | 278 | Needs that are provided by this identity should be added to the `provides` 279 | set after loading. 280 | """ 281 | def __init__(self, id: Optional[Any], auth_type: Optional[str] = None) -> None: 282 | self.id = id 283 | self.auth_type = auth_type 284 | self.provides: Set[Union[Need, ItemNeed]] = set() 285 | 286 | def can(self, permission: BasePermission) -> bool: 287 | """Whether the identity has access to the permission. 288 | 289 | :param permission: The permission to test provision for. 290 | """ 291 | return permission.allows(self) 292 | 293 | def __repr__(self) -> str: 294 | return '<{0} id="{1}" auth_type="{2}" provides={3}>'.format( 295 | self.__class__.__name__, self.id, self.auth_type, self.provides 296 | ) 297 | 298 | 299 | class AnonymousIdentity(Identity): 300 | """An anonymous identity""" 301 | 302 | def __init__(self) -> None: 303 | Identity.__init__(self, None) 304 | 305 | 306 | 307 | 308 | class _NaryOperatorPermission(BasePermission): 309 | 310 | def __init__(self, *permissions: BasePermission) -> None: 311 | self.permissions: Set[BasePermission] = set(permissions) 312 | 313 | 314 | # These classes would be unnecessary if we have predicate calculus 315 | # primatives of some kind. 316 | 317 | class OrPermission(_NaryOperatorPermission): 318 | """Result of bitwise ``or`` of BasePermission""" 319 | 320 | def allows(self, identity: Identity) -> bool: 321 | """ 322 | Checks for any of the nested permission instances that allow the 323 | identity and return True, else return False. 324 | 325 | :param identity: The identity. 326 | """ 327 | 328 | return any(p.allows(identity) for p in self.permissions) 329 | 330 | 331 | class AndPermission(_NaryOperatorPermission): 332 | """Result of bitwise ``and`` of BasePermission""" 333 | 334 | def allows(self, identity: Identity) -> bool: 335 | """ 336 | Checks for any of the nested permission instances that disallow 337 | the identity and return False, else return True. 338 | 339 | :param identity: The identity. 340 | """ 341 | 342 | return all(p.allows(identity) for p in self.permissions) 343 | 344 | 345 | class NotPermission(BasePermission): 346 | """ 347 | Result of bitwise ``not`` of BasePermission 348 | 349 | Really could be implemented by returning a transformed result of the 350 | source class of itself, but for the sake of clear presentation I am 351 | not doing that. 352 | """ 353 | 354 | def __init__(self, permission: BasePermission) -> None: 355 | self.permission = permission 356 | 357 | def invert(self) -> BasePermission: 358 | return self.permission 359 | 360 | def allows(self, identity: Identity) -> bool: 361 | return not self.permission.allows(identity) 362 | 363 | 364 | class Permission(BasePermission): 365 | """Represents needs, any of which must be present to access a resource 366 | 367 | :param needs: The needs for this permission 368 | """ 369 | def __init__(self, *needs: Union[Need, ItemNeed]) -> None: 370 | """A set of needs, any of which must be present in an identity to have 371 | access. 372 | """ 373 | 374 | self.perms: Dict[Union[Need, ItemNeed], bool] = {n: True for n in needs} 375 | 376 | def __or__(self, other: Union['Permission', BasePermission]) -> Union['Permission', BasePermission]: 377 | """Does the same thing as ``self.union(other)`` 378 | """ 379 | if isinstance(other, Permission): 380 | return self.union(other) 381 | return super(Permission, self).__or__(other) 382 | 383 | def __sub__(self, other: 'Permission') -> 'Permission': 384 | """Does the same thing as ``self.difference(other)`` 385 | """ 386 | return self.difference(other) 387 | 388 | def __contains__(self, other: 'Permission') -> bool: 389 | """Does the same thing as ``other.issubset(self)``. 390 | """ 391 | return other.issubset(self) 392 | 393 | def __repr__(self) -> str: 394 | return f'<{self.__class__.__name__} needs={self.needs} excludes={self.excludes}>' 395 | 396 | @property 397 | def needs(self) -> Set[Union[Need, ItemNeed]]: 398 | return {n for n, v in self.perms.items() if v} 399 | 400 | @property 401 | def excludes(self) -> Set[Union[Need, ItemNeed]]: 402 | return {n for n, v in self.perms.items() if not v} 403 | 404 | def reverse(self) -> 'Permission': 405 | """ 406 | Returns reverse of current state (needs->excludes, excludes->needs) 407 | """ 408 | 409 | p = Permission() 410 | # flipping the values determining whether or not the key 411 | # is a need or exclude 412 | p.perms.update({n: not v for n, v in self.perms.items()}) 413 | return p 414 | 415 | def union(self, other: 'Permission') -> 'Permission': 416 | """Create a new permission with the requirements of the union of this 417 | and other. 418 | 419 | :param other: The other permission 420 | """ 421 | p = Permission() 422 | # union-ing needs and excludes from both Permissions 423 | p.perms = { 424 | **{n: True for n in self.needs.union(other.needs)}, 425 | **{e: False for e in self.excludes.union(other.excludes)} 426 | } 427 | return p 428 | 429 | def difference(self, other: 'Permission') -> 'Permission': 430 | """Create a new permission consisting of requirements in this 431 | permission and not in the other. 432 | """ 433 | 434 | p = Permission() 435 | # diff-ing needs and excludes from both Permissions 436 | p.perms = { 437 | **{n: True for n in self.needs.difference(other.needs)}, 438 | **{e: False for e in self.excludes.difference(other.excludes)} 439 | } 440 | return p 441 | 442 | def issubset(self, other: 'Permission') -> bool: 443 | """Whether this permission needs are a subset of another 444 | 445 | :param other: The other permission 446 | """ 447 | return ( 448 | self.needs.issubset(other.needs) and 449 | self.excludes.issubset(other.excludes) 450 | ) 451 | 452 | def allows(self, identity: Identity) -> bool: 453 | """Whether the identity can access this permission. 454 | 455 | :param identity: The identity 456 | """ 457 | if self.needs and not self.needs.intersection(identity.provides): 458 | return False 459 | 460 | if self.excludes and self.excludes.intersection(identity.provides): 461 | return False 462 | 463 | return True 464 | 465 | 466 | class Denial(Permission): 467 | """ 468 | Shortcut class for passing excluded needs. 469 | """ 470 | 471 | def __init__(self, *excludes: Union[Need, ItemNeed]) -> None: 472 | self.perms = {e: False for e in excludes} 473 | 474 | 475 | def session_identity_loader() -> Optional[Identity]: 476 | if 'identity.id' in session and 'identity.auth_type' in session: 477 | identity = Identity(session['identity.id'], 478 | session['identity.auth_type']) 479 | return identity 480 | return None 481 | 482 | 483 | def session_identity_saver(identity: Identity) -> None: 484 | session['identity.id'] = identity.id 485 | session['identity.auth_type'] = identity.auth_type 486 | session.modified = True 487 | 488 | 489 | class Principal(object): 490 | """Principal extension 491 | 492 | :param app: The flask application to extend 493 | :param use_sessions: Whether to use sessions to extract and store 494 | identification. 495 | :param skip_static: Whether to ignore static endpoints. 496 | """ 497 | def __init__( 498 | self, 499 | app: Optional[Flask] = None, 500 | use_sessions: bool = True, 501 | skip_static: bool = False 502 | ) -> None: 503 | self.identity_loaders: Deque[Callable[[], Optional[Identity]]] = deque() 504 | self.identity_savers: Deque[Callable[[Identity], None]] = deque() 505 | # XXX This will probably vanish for a better API 506 | self.use_sessions = use_sessions 507 | self.skip_static = skip_static 508 | 509 | if app is not None: 510 | self.init_app(app) 511 | 512 | def _init_app(self, app: Flask) -> None: 513 | from warnings import warn 514 | warn(DeprecationWarning( 515 | '_init_app is deprecated, use the new init_app ' 516 | 'method instead.'), stacklevel=1 517 | ) 518 | self.init_app(app) 519 | 520 | def init_app(self, app: Flask) -> None: 521 | if hasattr(app, 'static_url_path'): 522 | self._static_path = app.static_url_path 523 | else: 524 | self._static_path = app.static_path # type: ignore 525 | 526 | app.before_request(self._on_before_request) 527 | identity_changed.connect(self._on_identity_changed, app) 528 | 529 | if self.use_sessions: 530 | self.identity_loader(session_identity_loader) 531 | self.identity_saver(session_identity_saver) 532 | 533 | def set_identity(self, identity: Identity) -> None: 534 | """Set the current identity. 535 | 536 | :param identity: The identity to set 537 | """ 538 | 539 | self._set_thread_identity(identity) 540 | for saver in self.identity_savers: 541 | saver(identity) 542 | 543 | def identity_loader(self, f: Callable[[], Optional[Identity]]) -> Callable[[], Optional[Identity]]: 544 | """Decorator to define a function as an identity loader. 545 | 546 | An identity loader function is called before request to find any 547 | provided identities. The first found identity is used to load from. 548 | 549 | For example:: 550 | 551 | app = Flask(__name__) 552 | 553 | principals = Principal(app) 554 | 555 | @principals.identity_loader 556 | def load_identity_from_weird_usecase(): 557 | return Identity('ali') 558 | """ 559 | self.identity_loaders.appendleft(f) 560 | return f 561 | 562 | def identity_saver(self, f: Callable[[Identity], None]) -> Callable[[Identity], None]: 563 | """Decorator to define a function as an identity saver. 564 | 565 | An identity loader saver is called when the identity is set to persist 566 | it for the next request. 567 | 568 | For example:: 569 | 570 | app = Flask(__name__) 571 | 572 | principals = Principal(app) 573 | 574 | @principals.identity_saver 575 | def save_identity_to_weird_usecase(identity): 576 | my_special_cookie['identity'] = identity 577 | """ 578 | self.identity_savers.appendleft(f) 579 | return f 580 | 581 | def _set_thread_identity(self, identity: Identity) -> None: 582 | g.identity = identity 583 | identity_loaded.send(current_app._get_current_object(), # type: ignore 584 | identity=identity) 585 | 586 | def _on_identity_changed(self, app: Flask, identity: Identity) -> None: 587 | if self._is_static_route(): 588 | return 589 | 590 | self.set_identity(identity) 591 | 592 | def _on_before_request(self) -> None: 593 | if self._is_static_route(): 594 | return 595 | 596 | g.identity = AnonymousIdentity() 597 | for loader in self.identity_loaders: 598 | identity = loader() 599 | if identity is not None: 600 | self.set_identity(identity) 601 | return 602 | 603 | def _is_static_route(self) -> bool: 604 | return bool( 605 | self.skip_static and 606 | self._static_path and 607 | request.path.startswith(self._static_path) 608 | ) 609 | -------------------------------------------------------------------------------- /tests/test_principal.py: -------------------------------------------------------------------------------- 1 | 2 | from __future__ import with_statement 3 | 4 | import unittest 5 | 6 | from flask import Flask, Response 7 | 8 | from flask_principal import BasePermission, OrPermission, AndPermission 9 | from flask_principal import NotPermission 10 | from flask_principal import Principal, Permission, Denial, RoleNeed, \ 11 | PermissionDenied, identity_changed, Identity, identity_loaded 12 | 13 | anon_permission = Permission() 14 | admin_permission = Permission(RoleNeed('admin')) 15 | admin_or_editor = Permission(RoleNeed('admin'), RoleNeed('editor')) 16 | editor_permission = Permission(RoleNeed('editor')) 17 | manager_permission = Permission(RoleNeed('manager')) 18 | admin_or_editor_or_manager = Permission( 19 | RoleNeed('admin'), RoleNeed('editor'), RoleNeed('manager')) 20 | 21 | admin_denied = Denial(RoleNeed('admin')) 22 | 23 | 24 | class RolenamePermission(BasePermission): 25 | def __init__(self, role): 26 | self.role = role 27 | def allows(self, identity): 28 | return RoleNeed(self.role) in identity.provides 29 | 30 | admin_role_permission = RolenamePermission('admin') 31 | editor_role_permission = RolenamePermission('editor') 32 | manager_role_permission = RolenamePermission('manager') 33 | reviewer_role_permission = RolenamePermission('reviewer') 34 | 35 | 36 | def _on_principal_init(sender, identity): 37 | role_map = { 38 | 'ali': (RoleNeed('admin'),), 39 | 'admin': (RoleNeed('admin'),), 40 | 'editor': (RoleNeed('editor'),), 41 | 'reviewer': (RoleNeed('reviewer'),), 42 | 'admin_editor': (RoleNeed('editor'), RoleNeed('admin')), 43 | 'manager': (RoleNeed('manager'),), 44 | 'manager_editor': (RoleNeed('editor'), RoleNeed('manager')), 45 | 'reviewer_editor': (RoleNeed('editor'), RoleNeed('reviewer')), 46 | 'admin_manager': (RoleNeed('admin'), RoleNeed('manager')), 47 | 'admin_editor_manager': ( 48 | RoleNeed('admin'), RoleNeed('editor'), RoleNeed('manager')), 49 | } 50 | 51 | roles = role_map.get(identity.id) 52 | if roles: 53 | for role in roles: 54 | identity.provides.add(role) 55 | 56 | 57 | class ReraiseException(Exception): 58 | """For checking reraising""" 59 | 60 | 61 | def mkapp(with_factory=False): 62 | app = Flask(__name__) 63 | app.secret_key = 'notverysecret' 64 | app.debug = True 65 | 66 | if with_factory: 67 | p = Principal() 68 | p.init_app(app) 69 | else: 70 | p = Principal(app) 71 | 72 | identity_loaded.connect(_on_principal_init) 73 | 74 | @app.route('/') 75 | def index(): 76 | with admin_permission.require(): 77 | pass 78 | return Response('hello') 79 | 80 | @app.route('/a') 81 | @admin_permission.require() 82 | def a(): 83 | return Response('hello') 84 | 85 | @app.route('/b') 86 | @anon_permission.require() 87 | def b(): 88 | return Response('hello') 89 | 90 | @app.route('/c') 91 | def c(): 92 | with anon_permission.require(): 93 | raise ReraiseException 94 | 95 | @app.route('/d') 96 | @anon_permission.require() 97 | def d(): 98 | raise ReraiseException 99 | 100 | @app.route('/e') 101 | def e(): 102 | i = mkadmin() 103 | identity_changed.send(app, identity=i) 104 | with admin_permission.require(): 105 | return Response('hello') 106 | 107 | @app.route('/f') 108 | def f(): 109 | i = mkadmin() 110 | identity_changed.send(app, identity=i) 111 | with admin_or_editor.require(): 112 | return Response('hello') 113 | 114 | @app.route('/and_base_fail') 115 | def and_base_fail(): 116 | i = mkadmin() 117 | admin_and_editor_rp = (admin_role_permission & editor_role_permission) 118 | identity_changed.send(app, identity=i) 119 | with admin_and_editor_rp.require(): 120 | return Response('fail') 121 | 122 | @app.route('/and_base_success') 123 | def and_base_success(): 124 | i = Identity('admin_editor') 125 | identity_changed.send(app, identity=i) 126 | # using both formerly default, calling parent __and__ 127 | admin_and_editor_rp = (admin_permission & editor_permission) 128 | with admin_and_editor_rp.require(): 129 | return Response('good') 130 | 131 | @app.route('/and_bunch') 132 | def and_bunch(): 133 | result = [] 134 | 135 | bunch = AndPermission( 136 | admin_role_permission, 137 | editor_role_permission, 138 | manager_role_permission, 139 | ) 140 | 141 | identity_changed.send(app, identity=Identity('admin')) 142 | if bunch.can(): 143 | result.append('bad') 144 | 145 | identity_changed.send(app, identity=Identity('manager')) 146 | if bunch.can(): 147 | result.append('bad') 148 | 149 | identity_changed.send(app, identity=Identity('reviewer')) 150 | if bunch.can(): 151 | result.append('bad') 152 | 153 | identity_changed.send(app, identity=Identity('admin_editor_manager')) 154 | if bunch.can(): 155 | result.append('good') 156 | 157 | return ''.join(result) 158 | 159 | @app.route('/and_mixed1') 160 | def and_mixed1(): 161 | admin_and_editor_mixed = (admin_role_permission & editor_permission) 162 | i = Identity('editor') 163 | identity_changed.send(app, identity=i) 164 | with admin_and_editor_mixed.require(): 165 | return Response('fail') 166 | 167 | @app.route('/and_mixed2') # reversed type of the above. 168 | def and_mixed2(): 169 | admin_and_editor_mixed = (admin_permission & editor_role_permission) 170 | i = Identity('admin_editor') 171 | identity_changed.send(app, identity=i) 172 | with admin_and_editor_mixed.require(): 173 | return Response('good') 174 | 175 | @app.route('/or_base') 176 | def or_base(): 177 | i = mkadmin() 178 | admin_or_editor_rp = (admin_role_permission | editor_role_permission) 179 | identity_changed.send(app, identity=i) 180 | with admin_or_editor_rp.require(): 181 | return Response('hello') 182 | 183 | @app.route('/or_bunch') 184 | def or_bunch(): 185 | result = [] 186 | 187 | bunch = OrPermission( 188 | admin_role_permission, 189 | editor_role_permission, 190 | manager_role_permission, 191 | reviewer_role_permission, 192 | ) 193 | 194 | identity_changed.send(app, identity=Identity('admin')) 195 | if bunch.can(): 196 | result.append('good') 197 | 198 | identity_changed.send(app, identity=Identity('manager')) 199 | if bunch.can(): 200 | result.append('good') 201 | 202 | identity_changed.send(app, identity=Identity('reviewer')) 203 | if bunch.can(): 204 | result.append('good') 205 | 206 | return ''.join(result) 207 | 208 | @app.route('/or_mixed1') 209 | def or_mixed1(): 210 | result = [] 211 | admin_or_editor_mixed = (admin_role_permission | editor_permission) 212 | 213 | i = Identity('admin') 214 | identity_changed.send(app, identity=i) 215 | with admin_or_editor_mixed.require(): 216 | result.append('good') 217 | 218 | i = Identity('editor') 219 | identity_changed.send(app, identity=i) 220 | with admin_or_editor_mixed.require(): 221 | result.append('good') 222 | 223 | return Response(''.join(result)) 224 | 225 | @app.route('/or_mixed2') # reversed type of the above. 226 | def or_mixed2(): 227 | result = [] 228 | admin_or_editor_mixed = (admin_permission | editor_role_permission) 229 | 230 | i = Identity('admin') 231 | identity_changed.send(app, identity=i) 232 | with admin_or_editor_mixed.require(): 233 | result.append('good') 234 | 235 | i = Identity('editor') 236 | identity_changed.send(app, identity=i) 237 | with admin_or_editor_mixed.require(): 238 | result.append('good') 239 | 240 | return Response(''.join(result)) 241 | 242 | @app.route('/not_base') 243 | def not_base(): 244 | result = [] 245 | not_admin_perm = ~admin_role_permission 246 | 247 | identity_changed.send(app, identity=Identity('admin')) 248 | if not_admin_perm.can(): 249 | result.append('admin') 250 | 251 | identity_changed.send(app, identity=Identity('editor')) 252 | if not_admin_perm.can(): 253 | result.append('editor') 254 | 255 | identity_changed.send(app, identity=Identity('admin_manager')) 256 | if not_admin_perm.can(): 257 | result.append('admin_manager') 258 | 259 | return Response(''.join(result)) 260 | 261 | @app.route('/mixed_ops_fail') 262 | def mixed_ops_fail(): 263 | mixed_perms = (admin_permission | manager_permission | 264 | (reviewer_role_permission & editor_role_permission)) 265 | 266 | i = Identity('editor') 267 | identity_changed.send(app, identity=i) 268 | with mixed_perms.require(): 269 | return Response('fail') 270 | 271 | @app.route('/mixed_ops1') 272 | def mixed_ops1(): 273 | result = [] 274 | mixed_perms = (admin_permission | manager_permission | 275 | (reviewer_role_permission & editor_role_permission)) 276 | 277 | i = Identity('reviewer_editor') 278 | identity_changed.send(app, identity=i) 279 | with mixed_perms.require(): 280 | result.append('good') 281 | 282 | i = Identity('manager') 283 | identity_changed.send(app, identity=i) 284 | with mixed_perms.require(): 285 | result.append('good') 286 | 287 | i = Identity('admin') 288 | identity_changed.send(app, identity=i) 289 | with mixed_perms.require(): 290 | result.append('good') 291 | 292 | return Response(''.join(result)) 293 | 294 | @app.route('/mixed_ops2') 295 | def mixed_ops2(): 296 | result = [] 297 | mixed_perms = ((admin_permission & editor_permission) | 298 | (manager_role_permission & editor_role_permission)) 299 | 300 | i = Identity('manager_editor') 301 | identity_changed.send(app, identity=i) 302 | if mixed_perms.can(): 303 | result.append('good') 304 | 305 | i = Identity('manager') 306 | identity_changed.send(app, identity=i) 307 | if mixed_perms.can(): 308 | result.append('bad') 309 | 310 | i = Identity('editor') 311 | identity_changed.send(app, identity=i) 312 | if mixed_perms.can(): 313 | result.append('bad') 314 | 315 | i = Identity('admin_editor') 316 | identity_changed.send(app, identity=i) 317 | if mixed_perms.can(): 318 | result.append('good') 319 | 320 | i = Identity('admin') 321 | identity_changed.send(app, identity=i) 322 | if mixed_perms.can(): 323 | result.append('bad') 324 | 325 | return Response(''.join(result)) 326 | 327 | @app.route('/mixed_ops3') 328 | def mixed_ops3(): 329 | result = [] 330 | mixed_perms = ( 331 | ((admin_permission & editor_permission) | 332 | (manager_role_permission & editor_role_permission)) & 333 | ~(manager_role_permission & admin_permission) & 334 | ~reviewer_role_permission 335 | ) 336 | 337 | i = Identity('manager_editor') 338 | identity_changed.send(app, identity=i) 339 | if mixed_perms.can(): 340 | result.append('good') 341 | 342 | i = Identity('admin_editor') 343 | identity_changed.send(app, identity=i) 344 | if mixed_perms.can(): 345 | result.append('good') 346 | 347 | i = Identity('admin_manager') 348 | identity_changed.send(app, identity=i) 349 | if mixed_perms.can(): 350 | result.append('bad') 351 | 352 | i = Identity('manager_editor_admin') 353 | identity_changed.send(app, identity=i) 354 | if mixed_perms.can(): 355 | result.append('bad') 356 | 357 | i = Identity('reviewer') 358 | identity_changed.send(app, identity=i) 359 | if mixed_perms.can(): 360 | result.append('bad') 361 | 362 | return Response(''.join(result)) 363 | 364 | @app.route('/g') 365 | @admin_permission.require() 366 | @editor_permission.require() 367 | def g(): 368 | return Response('hello') 369 | 370 | @app.route('/h') 371 | def h(): 372 | i = Identity('james') 373 | identity_changed.send(app, identity=i) 374 | with admin_permission.require(): 375 | with editor_permission.require(): 376 | pass 377 | 378 | @app.route('/j') 379 | def j(): 380 | i = Identity('james') 381 | identity_changed.send(app, identity=i) 382 | with admin_permission.require(403): 383 | with editor_permission.require(403): 384 | pass 385 | 386 | @app.route('/k') 387 | @admin_permission.require(403) 388 | def k(): 389 | return Response('hello') 390 | 391 | @app.route('/l') 392 | def l(): 393 | s = [] 394 | if not admin_or_editor: 395 | s.append("not admin_or_editor") 396 | if not (admin_permission or editor_permission): 397 | s.append("not (admin or editor)") 398 | 399 | i = Identity('ali') 400 | identity_changed.send(app, identity=i) 401 | if admin_or_editor: 402 | s.append("now admin_or_editor") 403 | if admin_permission or editor_permission: 404 | s.append("now admin or editor") 405 | return Response('\n'.join(s)) 406 | 407 | @app.route("/m") 408 | def m(): 409 | with admin_denied.require(): 410 | pass 411 | 412 | return Response("OK") 413 | 414 | @app.route("/n") 415 | def n(): 416 | i = mkadmin() 417 | identity_changed.send(app, identity=i) 418 | with admin_denied.require(): 419 | pass 420 | 421 | return Response("OK") 422 | 423 | @app.route("/o") 424 | def o(): 425 | admin_or_editor.test() 426 | return Response("OK") 427 | 428 | @app.route("/o2") 429 | def o2(): 430 | (admin_permission | editor_permission).test() 431 | return Response("OK") 432 | 433 | @app.route("/o3") 434 | def o3(): 435 | i = mkadmin() 436 | identity_changed.send(app, identity=i) 437 | (admin_permission | editor_permission).test() 438 | return Response("OK") 439 | 440 | @app.route("/p") 441 | def p(): 442 | admin_or_editor.test(404) 443 | return Response("OK") 444 | 445 | return app 446 | 447 | 448 | def mkadmin(): 449 | i = Identity('ali') 450 | return i 451 | 452 | 453 | class BasePermissionUnitTests(unittest.TestCase): 454 | 455 | def test_or_permission(self): 456 | admin_or_editor_rp = (admin_role_permission | editor_role_permission) 457 | self.assertTrue(isinstance(admin_or_editor_rp, OrPermission)) 458 | self.assertEqual(admin_or_editor_rp.permissions, 459 | set([admin_role_permission, editor_role_permission])) 460 | 461 | def test_and_permission(self): 462 | admin_and_editor_rp = (admin_role_permission & editor_role_permission) 463 | self.assertTrue(isinstance(admin_and_editor_rp, AndPermission)) 464 | self.assertEqual(admin_and_editor_rp.permissions, 465 | set([admin_role_permission, editor_role_permission])) 466 | 467 | # TODO test manual construction 468 | 469 | 470 | class PrincipalUnitTests(unittest.TestCase): 471 | 472 | def test_permission_union(self): 473 | p1 = Permission(('a', 'b')) 474 | p2 = Permission(('a', 'c')) 475 | p3 = p1.union(p2) 476 | assert p1.issubset(p3) 477 | assert p2.issubset(p3) 478 | 479 | def test_permission_difference(self): 480 | p1 = Permission(('a', 'b'), ('a', 'c')) 481 | p2 = Permission(('a', 'c'), ('d', 'e')) 482 | p3 = p1.difference(p2) 483 | assert p3.needs == set([('a', 'b')]) 484 | p4 = p2.difference(p1) 485 | assert p4.needs == set([('d', 'e')]) 486 | 487 | def test_permission_union_denial(self): 488 | p1 = Permission(('a', 'b')) 489 | p2 = Denial(('a', 'c')) 490 | p3 = p1.union(p2) 491 | assert p1.issubset(p3) 492 | assert p2.issubset(p3) 493 | 494 | def test_permission_difference_denial(self): 495 | p1 = Denial(('a', 'b'), ('a', 'c')) 496 | p2 = Denial(('a', 'c'), ('d', 'e')) 497 | p3 = p1.difference(p2) 498 | assert p3.excludes == set([('a', 'b')]) 499 | p4 = p2.difference(p1) 500 | assert p4.excludes == set([('d', 'e')]) 501 | 502 | def test_reverse_permission(self): 503 | p = Permission(('a', 'b')) 504 | d = p.reverse() 505 | assert ('a', 'b') in d.excludes 506 | 507 | def test_permission_difference(self): 508 | p1 = Permission(RoleNeed('boss')) 509 | p2 = Permission(RoleNeed('lackey')) 510 | 511 | p3 = p1 - p2 512 | p4 = p1.difference(p2) 513 | 514 | # parity with set operations 515 | p3needs = p1.needs - p2.needs 516 | 517 | assert p3.needs == p4.needs 518 | assert p3.needs == p3needs 519 | 520 | def test_permission_difference_excludes(self): 521 | p1 = Permission(RoleNeed('boss')).reverse() 522 | p2 = Permission(RoleNeed('lackey')).reverse() 523 | 524 | p3 = p1 - p2 525 | p4 = p1.difference(p2) 526 | 527 | # parity with set operations 528 | p3excludes = p1.excludes - p2.excludes 529 | 530 | assert p3.excludes == p4.excludes 531 | assert p3.excludes == p3excludes 532 | 533 | def test_permission_or(self): 534 | p1 = Permission(RoleNeed('boss'), RoleNeed('lackey')) 535 | p2 = Permission(RoleNeed('lackey'), RoleNeed('underling')) 536 | 537 | p3 = p1 | p2 538 | p4 = p1.union(p2) 539 | 540 | # Ensure that an `or` between sets also result in the expected 541 | # behavior. As expected, as "any of which must be present to 542 | # access a resource". 543 | p3needs = p1.needs | p2.needs 544 | 545 | assert p3.needs == p4.needs 546 | assert p3.needs == p3needs 547 | 548 | def test_permission_or_excludes(self): 549 | p1 = Permission(RoleNeed('boss'), RoleNeed('lackey')).reverse() 550 | p2 = Permission(RoleNeed('lackey'), RoleNeed('underling')).reverse() 551 | 552 | p3 = p1 | p2 553 | p4 = p1.union(p2) 554 | 555 | # Ensure that an `or` between sets also result in the expected 556 | # behavior. As expected, as "any of which must be present to 557 | # access a resource". 558 | p3excludes = p1.excludes | p2.excludes 559 | 560 | assert p3.excludes == p4.excludes 561 | assert p3.excludes == p3excludes 562 | 563 | def test_permission_not(self): 564 | p1 = Permission(RoleNeed('boss'), RoleNeed('lackey')) 565 | p2 = ~p1 566 | p3 = ~p2 567 | assert p3 == p1 568 | 569 | def test_contains(self): 570 | p1 = Permission(RoleNeed('boss'), RoleNeed('lackey')) 571 | p2 = Permission(RoleNeed('lackey')) 572 | 573 | assert p2.issubset(p1) 574 | assert p2 in p1 575 | 576 | 577 | class PrincipalApplicationTests(unittest.TestCase): 578 | 579 | def setUp(self): 580 | self.client = mkapp().test_client() 581 | 582 | def test_deny_with(self): 583 | self.assertRaises(PermissionDenied, self.client.open, '/') 584 | 585 | def test_deny_view(self): 586 | self.assertRaises(PermissionDenied, self.client.open, '/a') 587 | 588 | def test_allow_view(self): 589 | assert self.client.open('/b').data == b'hello' 590 | 591 | def test_reraise(self): 592 | self.assertRaises(ReraiseException, self.client.open, '/c') 593 | 594 | def test_error_view(self): 595 | self.assertRaises(ReraiseException, self.client.open, '/d') 596 | 597 | def test_identity_changed(self): 598 | assert self.client.open('/e').data == b'hello' 599 | 600 | def test_identity_load(self): 601 | assert self.client.open('/e').data == b'hello' 602 | assert self.client.open('/a').data == b'hello' 603 | 604 | def test_or_permissions(self): 605 | assert self.client.open('/e').data == b'hello' 606 | assert self.client.open('/f').data == b'hello' 607 | 608 | def test_base_or_permissions(self): 609 | assert self.client.open('/or_base').data == b'hello' 610 | 611 | def test_or_permissions_bunch(self): 612 | self.assertEqual(self.client.open('/or_bunch').data, b'goodgoodgood') 613 | 614 | def test_base_not_permissions(self): 615 | self.assertEqual(self.client.open('/not_base').data, b'editor') 616 | 617 | def test_mixed_or_permissions(self): 618 | assert self.client.open('/or_mixed1').data == b'goodgood' 619 | assert self.client.open('/or_mixed2').data == b'goodgood' 620 | 621 | def test_base_and_permissions(self): 622 | self.assertRaises(PermissionDenied, self.client.open, '/and_base_fail') 623 | self.assertEqual(self.client.open('/and_base_success').data, b'good') 624 | 625 | def test_mixed_and_permissions(self): 626 | self.assertRaises(PermissionDenied, self.client.open, '/and_mixed1') 627 | self.assertEqual(self.client.open('/and_mixed2').data, b'good') 628 | 629 | def test_mixed_and_or_permissions_fail(self): 630 | self.assertRaises(PermissionDenied, 631 | self.client.open, '/mixed_ops_fail') 632 | 633 | def test_mixed_and_or_permissions(self): 634 | self.assertEqual(self.client.open('/mixed_ops1').data, b'goodgoodgood') 635 | self.assertEqual(self.client.open('/mixed_ops2').data, b'goodgood') 636 | self.assertEqual(self.client.open('/mixed_ops3').data, b'goodgood') 637 | 638 | def test_and_permissions_view_denied(self): 639 | self.assertRaises(PermissionDenied, self.client.open, '/g') 640 | 641 | def test_and_permissions_view(self): 642 | self.assertRaises(PermissionDenied, self.client.open, '/g') 643 | 644 | def test_and_permissions_view_denied_identity_changed(self): 645 | self.assertRaises(PermissionDenied, self.client.open, '/h') 646 | 647 | def test_and_permissions_view_identity_changed(self): 648 | self.assertRaises(PermissionDenied, self.client.open, '/h') 649 | 650 | def test_and_permissions_view_with_http_exc(self): 651 | response = self.client.open("/j") 652 | assert response.status_code == 403 653 | 654 | def test_and_permissions_view_with_http_exc_decorated(self): 655 | response = self.client.open("/k") 656 | assert response.status_code == 403 657 | 658 | def test_and_permissions_bunch(self): 659 | self.assertEqual(self.client.open('/and_bunch').data, b'good') 660 | 661 | def test_and_permissions_view_with_custom_errhandler(self): 662 | app = mkapp() 663 | 664 | @app.errorhandler(403) 665 | def handle_permission_denied(error): 666 | assert error.description == admin_permission 667 | return Response("OK") 668 | 669 | self.client = app.test_client() 670 | response = self.client.open("/k") 671 | assert response.status_code == 200 672 | 673 | def test_permission_bool(self): 674 | response = self.client.open('/l') 675 | assert response.status_code == 200 676 | assert b'not admin_or_editor' in response.data 677 | assert b'not (admin or editor)' in response.data 678 | assert b'now admin_or_editor' in response.data 679 | assert b'now admin or editor' in response.data 680 | 681 | def test_denied_passes(self): 682 | response = self.client.open("/m") 683 | assert response.status_code == 200 684 | 685 | def test_denied_fails(self): 686 | self.assertRaises(PermissionDenied, self.client.open, '/n') 687 | 688 | def test_permission_test(self): 689 | self.assertRaises(PermissionDenied, self.client.open, '/o') 690 | 691 | def test_permission_operator_test(self): 692 | self.assertRaises(PermissionDenied, self.client.open, '/o2') 693 | 694 | response = self.client.open('/o3') 695 | assert response.status_code == 200 696 | assert response.data == b'OK' 697 | 698 | def test_permission_test_with_http_exc(self): 699 | response = self.client.open("/p") 700 | assert response.status_code == 404 701 | 702 | 703 | class FactoryMethodPrincipalApplicationTests(PrincipalApplicationTests): 704 | def setUp(self): 705 | self.client = mkapp(with_factory=True).test_client() 706 | --------------------------------------------------------------------------------