pax_global_header00006660000000000000000000000064137237565360014533gustar00rootroot0000000000000052 comment=537d364d26d8908646dda130ae716d24a6991a09 qtpynodeeditor-0.2.0/000077500000000000000000000000001372375653600146045ustar00rootroot00000000000000qtpynodeeditor-0.2.0/.codecov.yml000066400000000000000000000002621372375653600170270ustar00rootroot00000000000000# show coverage in CI status, not as a comment. comment: off coverage: status: project: default: target: auto patch: default: target: auto qtpynodeeditor-0.2.0/.coveragerc000066400000000000000000000002241372375653600167230ustar00rootroot00000000000000[run] source = qtpynodeeditor [report] omit = #versioning .*version.* *_version.py #tests *test* qtpynodeeditor/tests/* qtpynodeeditor-0.2.0/.flake8000066400000000000000000000003211372375653600157530ustar00rootroot00000000000000[flake8] ignore = W503,W504 exclude = .git __pycache__ qtpynodeeditor/_version.py build dist versioneer.py qtpynodeeditor/_version.py docs/source/conf.py max-line-length = 115 qtpynodeeditor-0.2.0/.gitattributes000066400000000000000000000000441372375653600174750ustar00rootroot00000000000000nodeeditor/_version.py export-subst qtpynodeeditor-0.2.0/.github/000077500000000000000000000000001372375653600161445ustar00rootroot00000000000000qtpynodeeditor-0.2.0/.github/ISSUE_TEMPLATE.md000066400000000000000000000020541372375653600206520ustar00rootroot00000000000000 ## Expected Behavior ## Current Behavior ## Possible Solution ## Steps to Reproduce (for bugs) 1. 2. 3. ## Context ## Your Environment qtpynodeeditor-0.2.0/.github/PULL_REQUEST_TEMPLATE.md000066400000000000000000000013571372375653600217530ustar00rootroot00000000000000 ## Description ## Motivation and Context ## How Has This Been Tested? ## Where Has This Been Documented? qtpynodeeditor-0.2.0/.gitignore000066400000000000000000000015741372375653600166030ustar00rootroot00000000000000# Byte-compiled / optimized / DLL files __pycache__/ *.py[cod] *$py.class # C extensions *.so # Distribution / packaging .Python env/ build/ develop-eggs/ dist/ downloads/ eggs/ .eggs/ lib/ lib64/ parts/ sdist/ var/ venv/ *.egg-info/ .installed.cfg *.egg # PyInstaller # Usually these files are written by a python script from a template # before PyInstaller builds the exe, so as to inject date/other infos into it. *.manifest *.spec # Installer logs pip-log.txt pip-delete-this-directory.txt # Unit test / coverage reports htmlcov/ .tox/ .coverage .coverage.* .cache nosetests.xml coverage.xml *,cover .hypothesis/ # Translations *.mo *.pot # Django stuff: *.log # Sphinx documentation docs/build/ docs/source/generated/ # pytest .pytest_cache/ # PyBuilder target/ # Editor files # OSX stuff .DS_Store *~ #vim *.sw[op] #pycharm .idea/* #Ipython Notebook .ipynb_checkpoints qtpynodeeditor-0.2.0/.pylintrc000066400000000000000000000421371372375653600164600ustar00rootroot00000000000000[MASTER] # A comma-separated list of package or module names from where C extensions may # be loaded. Extensions are loading into the active Python interpreter and may # run arbitrary code. extension-pkg-whitelist=qtpy,PyQt5 # Add files or directories to the blacklist. They should be base names, not # paths. ignore=CVS # Add files or directories matching the regex patterns to the blacklist. The # regex matches against base names, not paths. ignore-patterns= # Python code to execute, usually for sys.path manipulation such as # pygtk.require(). #init-hook= # Use multiple processes to speed up Pylint. Specifying 0 will auto-detect the # number of processors available to use. jobs=1 # Control the amount of potential inferred values when inferring a single # object. This can help the performance when dealing with large functions or # complex, nested conditions. limit-inference-results=100 # List of plugins (as comma separated values of python modules names) to load, # usually to register additional checkers. load-plugins= # Pickle collected data for later comparisons. persistent=yes # Specify a configuration file. #rcfile= # When enabled, pylint would attempt to guess common misconfiguration and emit # user-friendly hints instead of false-positive error messages. suggestion-mode=yes # Allow loading of arbitrary C extensions. Extensions are imported into the # active Python interpreter and may run arbitrary code. unsafe-load-any-extension=no [MESSAGES CONTROL] # Only show warnings with the listed confidence levels. Leave empty to show # all. Valid levels: HIGH, INFERENCE, INFERENCE_FAILURE, UNDEFINED. confidence= # Disable the message, report, category or checker with the given id(s). You # can either give multiple identifiers separated by comma (,) or put this # option multiple times (only on the command line, not in the configuration # file where it should appear only once). You can also use "--disable=all" to # disable everything first and then reenable specific checks. For example, if # you want to run only the similarities checker, you can use "--disable=all # --enable=similarities". If you want to run only the classes checker, but have # no Warning level messages displayed, use "--disable=all --enable=classes # --disable=W". disable=R, print-statement, parameter-unpacking, unpacking-in-except, old-raise-syntax, backtick, long-suffix, old-ne-operator, old-octal-literal, import-star-module-level, non-ascii-bytes-literal, raw-checker-failed, bad-inline-option, locally-disabled, file-ignored, suppressed-message, useless-suppression, deprecated-pragma, use-symbolic-message-instead, apply-builtin, basestring-builtin, buffer-builtin, cmp-builtin, coerce-builtin, execfile-builtin, file-builtin, long-builtin, raw_input-builtin, reduce-builtin, standarderror-builtin, unicode-builtin, xrange-builtin, coerce-method, delslice-method, getslice-method, setslice-method, no-absolute-import, old-division, dict-iter-method, dict-view-method, next-method-called, metaclass-assignment, indexing-exception, raising-string, reload-builtin, oct-method, hex-method, nonzero-method, cmp-method, input-builtin, round-builtin, intern-builtin, unichr-builtin, map-builtin-not-iterating, zip-builtin-not-iterating, range-builtin-not-iterating, filter-builtin-not-iterating, using-cmp-argument, eq-without-hash, div-method, idiv-method, rdiv-method, exception-message-attribute, invalid-str-codec, sys-max-int, bad-python3-import, deprecated-string-function, deprecated-str-translate-call, deprecated-itertools-function, deprecated-types-field, next-method-defined, dict-items-not-iterating, dict-keys-not-iterating, dict-values-not-iterating, deprecated-operator-function, deprecated-urllib-function, xreadlines-attribute, deprecated-sys-function, exception-escape, comprehension-escape # Enable the message, report, category or checker with the given id(s). You can # either give multiple identifier separated by comma (,) or put this option # multiple time (only on the command line, not in the configuration file where # it should appear only once). See also the "--disable" option for examples. enable=c-extension-no-member [REPORTS] # Python expression which should return a note less than 10 (10 is the highest # note). You have access to the variables errors warning, statement which # respectively contain the number of errors / warnings messages and the total # number of statements analyzed. This is used by the global evaluation report # (RP0004). evaluation=10.0 - ((float(5 * error + warning + refactor + convention) / statement) * 10) # Template used to display messages. This is a python new-style format string # used to format the message information. See doc for all details. #msg-template= # Set the output format. Available formats are text, parseable, colorized, json # and msvs (visual studio). You can also give a reporter class, e.g. # mypackage.mymodule.MyReporterClass. output-format=text # Tells whether to display a full report or only the messages. reports=no # Activate the evaluation score. score=yes [REFACTORING] # Maximum number of nested blocks for function / method body max-nested-blocks=5 # Complete name of functions that never returns. When checking for # inconsistent-return-statements if a never returning function is called then # it will be considered as an explicit return statement and no message will be # printed. never-returning-functions=sys.exit [LOGGING] # Format style used to check logging format string. `old` means using % # formatting, while `new` is for `{}` formatting. logging-format-style=old # Logging modules to check that the string format arguments are in logging # function parameter format. logging-modules=logging [SPELLING] # Limits count of emitted suggestions for spelling mistakes. max-spelling-suggestions=4 # Spelling dictionary name. Available dictionaries: none. To make it working # install python-enchant package.. spelling-dict= # List of comma separated words that should not be checked. spelling-ignore-words= # A path to a file that contains private dictionary; one word per line. spelling-private-dict-file= # Tells whether to store unknown words to indicated private dictionary in # --spelling-private-dict-file option instead of raising a message. spelling-store-unknown-words=no [MISCELLANEOUS] # List of note tags to take in consideration, separated by a comma. notes=FIXME, XXX, TODO [TYPECHECK] # List of decorators that produce context managers, such as # contextlib.contextmanager. Add to this list to register other decorators that # produce valid context managers. contextmanager-decorators=contextlib.contextmanager # List of members which are set dynamically and missed by pylint inference # system, and so shouldn't trigger E1101 when accessed. Python regular # expressions are accepted. generated-members= # Tells whether missing members accessed in mixin class should be ignored. A # mixin class is detected if its name ends with "mixin" (case insensitive). ignore-mixin-members=yes # Tells whether to warn about missing members when the owner of the attribute # is inferred to be None. ignore-none=yes # This flag controls whether pylint should warn about no-member and similar # checks whenever an opaque object is returned when inferring. The inference # can return multiple potential results while evaluating a Python object, but # some branches might not be evaluated, which results in partial inference. In # that case, it might be useful to still emit no-member and other checks for # the rest of the inferred objects. ignore-on-opaque-inference=yes # List of class names for which member attributes should not be checked (useful # for classes with dynamically set attributes). This supports the use of # qualified names. ignored-classes=optparse.Values,thread._local,_thread._local # List of module names for which member attributes should not be checked # (useful for modules/projects where namespaces are manipulated during runtime # and thus existing member attributes cannot be deduced by static analysis. It # supports qualified module names, as well as Unix pattern matching. ignored-modules= # Show a hint with possible names when a member name was not found. The aspect # of finding the hint is based on edit distance. missing-member-hint=yes # The minimum edit distance a name should have in order to be considered a # similar match for a missing member name. missing-member-hint-distance=1 # The total number of similar names that should be taken in consideration when # showing a hint for a missing member. missing-member-max-choices=1 [VARIABLES] # List of additional names supposed to be defined in builtins. Remember that # you should avoid defining new builtins when possible. additional-builtins= # Tells whether unused global variables should be treated as a violation. allow-global-unused-variables=yes # List of strings which can identify a callback function by name. A callback # name must start or end with one of those strings. callbacks=cb_, _cb # A regular expression matching the name of dummy variables (i.e. expected to # not be used). dummy-variables-rgx=_+$|(_[a-zA-Z0-9_]*[a-zA-Z0-9]+?$)|dummy|^ignored_|^unused_ # Argument names that match this expression will be ignored. Default to name # with leading underscore. ignored-argument-names=_.*|^ignored_|^unused_ # Tells whether we should check for unused import in __init__ files. init-import=no # List of qualified module names which can have objects that can redefine # builtins. redefining-builtins-modules=six.moves,past.builtins,future.builtins,builtins,io [FORMAT] # Expected format of line ending, e.g. empty (any line ending), LF or CRLF. expected-line-ending-format= # Regexp for a line that is allowed to be longer than the limit. ignore-long-lines=^\s*(# )??$ # Number of spaces of indent required inside a hanging or continued line. indent-after-paren=4 # String used as indentation unit. This is usually " " (4 spaces) or "\t" (1 # tab). indent-string=' ' # Maximum number of characters on a single line. max-line-length=100 # Maximum number of lines in a module. max-module-lines=1000 # List of optional constructs for which whitespace checking is disabled. `dict- # separator` is used to allow tabulation in dicts, etc.: {1 : 1,\n222: 2}. # `trailing-comma` allows a space between comma and closing bracket: (a, ). # `empty-line` allows space-only lines. no-space-check=trailing-comma, dict-separator # Allow the body of a class to be on the same line as the declaration if body # contains single statement. single-line-class-stmt=no # Allow the body of an if to be on the same line as the test if there is no # else. single-line-if-stmt=no [SIMILARITIES] # Ignore comments when computing similarities. ignore-comments=yes # Ignore docstrings when computing similarities. ignore-docstrings=yes # Ignore imports when computing similarities. ignore-imports=no # Minimum lines number of a similarity. min-similarity-lines=4 [BASIC] # Naming style matching correct argument names. argument-naming-style=snake_case # Regular expression matching correct argument names. Overrides argument- # naming-style. #argument-rgx= # Naming style matching correct attribute names. attr-naming-style=snake_case # Regular expression matching correct attribute names. Overrides attr-naming- # style. #attr-rgx= # Bad variable names which should always be refused, separated by a comma. bad-names=foo, bar, baz, toto, tutu, tata # Naming style matching correct class attribute names. class-attribute-naming-style=any # Regular expression matching correct class attribute names. Overrides class- # attribute-naming-style. #class-attribute-rgx= # Naming style matching correct class names. class-naming-style=PascalCase # Regular expression matching correct class names. Overrides class-naming- # style. #class-rgx= # Naming style matching correct constant names. const-naming-style=UPPER_CASE # Regular expression matching correct constant names. Overrides const-naming- # style. #const-rgx= # Minimum line length for functions/classes that require docstrings, shorter # ones are exempt. docstring-min-length=-1 # Naming style matching correct function names. function-naming-style=snake_case # Regular expression matching correct function names. Overrides function- # naming-style. #function-rgx= # Good variable names which should always be accepted, separated by a comma. good-names=i, j, k, ex, Run, _ # Include a hint for the correct naming format with invalid-name. include-naming-hint=no # Naming style matching correct inline iteration names. inlinevar-naming-style=any # Regular expression matching correct inline iteration names. Overrides # inlinevar-naming-style. #inlinevar-rgx= # Naming style matching correct method names. method-naming-style=snake_case # Regular expression matching correct method names. Overrides method-naming- # style. #method-rgx= # Naming style matching correct module names. module-naming-style=snake_case # Regular expression matching correct module names. Overrides module-naming- # style. #module-rgx= # Colon-delimited sets of names that determine each other's naming style when # the name regexes allow several styles. name-group= # Regular expression which should only match function or class names that do # not require a docstring. no-docstring-rgx=^_ # List of decorators that produce properties, such as abc.abstractproperty. Add # to this list to register other decorators that produce valid properties. # These decorators are taken in consideration only for invalid-name. property-classes=abc.abstractproperty # Naming style matching correct variable names. variable-naming-style=snake_case # Regular expression matching correct variable names. Overrides variable- # naming-style. #variable-rgx= [STRING] # This flag controls whether the implicit-str-concat-in-sequence should # generate a warning on implicit string concatenation in sequences defined over # several lines. check-str-concat-over-line-jumps=no [IMPORTS] # Allow wildcard imports from modules that define __all__. allow-wildcard-with-all=no # Analyse import fallback blocks. This can be used to support both Python 2 and # 3 compatible code, which means that the block might have code that exists # only in one or another interpreter, leading to false positives when analysed. analyse-fallback-blocks=no # Deprecated modules which should not be used, separated by a comma. deprecated-modules=optparse,tkinter.tix # Create a graph of external dependencies in the given file (report RP0402 must # not be disabled). ext-import-graph= # Create a graph of every (i.e. internal and external) dependencies in the # given file (report RP0402 must not be disabled). import-graph= # Create a graph of internal dependencies in the given file (report RP0402 must # not be disabled). int-import-graph= # Force import order to recognize a module as part of the standard # compatibility libraries. known-standard-library= # Force import order to recognize a module as part of a third party library. known-third-party=enchant [CLASSES] # List of method names used to declare (i.e. assign) instance attributes. defining-attr-methods=__init__, __new__, setUp # List of member names, which should be excluded from the protected access # warning. exclude-protected=_asdict, _fields, _replace, _source, _make # List of valid names for the first argument in a class method. valid-classmethod-first-arg=cls # List of valid names for the first argument in a metaclass class method. valid-metaclass-classmethod-first-arg=cls [DESIGN] # Maximum number of arguments for function / method. max-args=5 # Maximum number of attributes for a class (see R0902). max-attributes=7 # Maximum number of boolean expressions in an if statement. max-bool-expr=5 # Maximum number of branch for function / method body. max-branches=12 # Maximum number of locals for function / method body. max-locals=15 # Maximum number of parents for a class (see R0901). max-parents=7 # Maximum number of public methods for a class (see R0904). max-public-methods=20 # Maximum number of return / yield for function / method body. max-returns=6 # Maximum number of statements in function / method body. max-statements=50 # Minimum number of public methods for a class (see R0903). min-public-methods=2 [EXCEPTIONS] # Exceptions that will emit a warning when being caught. Defaults to # "BaseException, Exception". overgeneral-exceptions=BaseException, Exception qtpynodeeditor-0.2.0/.travis.yml000066400000000000000000000074001372375653600167160ustar00rootroot00000000000000language: python dist: xenial sudo: false services: - xvfb addons: apt: packages: - herbstluftwm - libxkbcommon-x11-0 env: global: - OFFICIAL_REPO="klauer/qtpynodeeditor" # Doctr deploy key for klauer/qtpynodeeditor - secure: "gXB0qZy/xd9U7RYKWexhqzmWR6SJzQ+YI3QjNuBBRc2ZprottF+V2+nX0kTtacYvSBilIdsKaF9qm2Xr0yvRynMLiSyU9IDRCFEiBVJQcMZUD+2EO69hDUmxLDf5w29aCEZfpNdzEx0InWBU4YctaWbLandwvPH7uI/wsWYPHPUewabpp5m2b5UBGvUswYvbBCZF5Mu+/DoAHVeJfJ2nA07BqsdpK5JHX1i+Q9JgZqC4u9o4wDtdGevhhSaMiywZWc/MjJFb50QPCOV9Ad7z2HusF9cuuJG4H2d+7wIqfOb89OxUoyrFS2TqdEltbkc+j7vqkqrEGKJIIPIMj6STHUc/RSRg+MFgpX19r1/sbP3RfWXYqBEIlokAFPw/LUo5lyyrphy4zg7PWRvLZbtx9dY/30W6PG2ZPHZiX247r9VeRlLHOWSXfw+uUNE6QzkqvsChCOOpA3WUcG+5QgZGYTaK+5PkeFI+6/+Dh9TZjwlsFa8YuOBHeeDQGXBvz9bsFPkhT8MZkJNXiB4FWVYucbERUsqmsNhk/XvGBKRjkbZoDyqONWWaOHvAIdBTNrko+M7yMpDhKol0azTAwJhT4lwtjsAsn37bq7F7zoOOmJj9NVMSt97IP+yBPfIr70JYtVxvuVTrTyfb55LCg3KMaMujk2p2KCR7AGoerj/Sx/s=" cache: directories: - $HOME/.cache/pip - $HOME/.ccache # https://github.com/travis-ci/travis-ci/issues/5853 matrix: fast_finish: true include: - python: 3.6 env: - CHECK_STYLE=1 - python: 3.6 env: - CONDA_UPLOAD=1 - BUILD_DOCS=1 - QT_API=pyqt5 - python: 3.7 env: - QT_API=pyqt5 - python: 3.6 env: - QT_API=PySide2 - python: 3.7 env: - QT_API=PySide2 install: - | if [[ "$CHECK_STYLE" == "1" ]]; then echo "Checking style with flake8..." pip install flake8 flake8 qtpynodeeditor FLAKE8_EXIT=$? echo "flake8 exited with code $FLAKE8_EXIT" exit $FLAKE8_EXIT fi # Ensure pip is up-to-date - pip install --upgrade pip # Install requirements - pip install -Ur requirements.txt # Install additional development requirements - pip install -Ur dev-requirements.txt # Install the package - pip install -e . # Install the specific binding we're testing against - pip install "${QT_API}" before_script: # Run the window manager - "herbstluftwm &" - sleep 1 script: - export PYTEST_QT_API="${QT_API}" - coverage run -m pytest -vv qtpynodeeditor/tests - set -e - | if [[ "$BUILD_DOCS" == "1" ]]; then # Create HTML documentation pushd docs make html popd #Publish docs. doctr deploy . --built-docs docs/build/html --deploy-branch-name gh-pages --command "touch .nojekyll; git add .nojekyll" fi after_success: - coverage report -m - codecov - | if [[ "$CONDA_UPLOAD" == "1" ]]; then # Install and configure miniconda wget -q https://repo.continuum.io/miniconda/Miniconda3-latest-Linux-x86_64.sh -O miniconda.sh bash miniconda.sh -b -p $HOME/miniconda export PATH="$HOME/miniconda/bin:$PATH" hash -r conda config --set always_yes yes --set changeps1 no # Ensure all packages are up-to-date conda update -q conda conda install conda-build anaconda-client conda config --append channels conda-forge conda info -a # Build the conda recipe for this package conda build -q conda-recipe --python=$TRAVIS_PYTHON_VERSION --output-folder bld-dir conda config --add channels "file://`pwd`/bld-dir" # Create the test environment conda create -q -n test-environment python=$TRAVIS_PYTHON_VERSION qtpynodeeditor --file requirements.txt source deactivate source activate test-environment if [[ $TRAVIS_PULL_REQUEST == false && $TRAVIS_REPO_SLUG == $OFFICIAL_REPO ]]; then if [[ $TRAVIS_BRANCH == $TRAVIS_TAG && $TRAVIS_TAG != '' ]]; then export ANACONDA_API_TOKEN=$CONDA_UPLOAD_TOKEN_TAG anaconda upload bld-dir/linux-64/*.tar.bz2 fi fi fi qtpynodeeditor-0.2.0/AUTHORS.rst000066400000000000000000000002021372375653600164550ustar00rootroot00000000000000======= Credits ======= Maintainer ---------- * Ken Lauer @klauer Contributors ------------ Interested? See: CONTRIBUTING.rst qtpynodeeditor-0.2.0/CONTRIBUTING.rst000066400000000000000000000060041372375653600172450ustar00rootroot00000000000000============ Contributing ============ Contributions are welcome, and they are greatly appreciated! Every little bit helps, and credit will always be given. You can contribute in many ways: Types of Contributions ---------------------- Report Bugs ~~~~~~~~~~~ Report bugs at https://github.com/klauer/qtpynodeeditor/issues. If you are reporting a bug, please include: * Any details about your local setup that might be helpful in troubleshooting. * Detailed steps to reproduce the bug. Fix Bugs ~~~~~~~~ Look through the GitHub issues for bugs. Anything tagged with "bug" is open to whoever wants to implement it. Implement Features ~~~~~~~~~~~~~~~~~~ Look through the GitHub issues for features. Anything tagged with "feature" is open to whoever wants to implement it. Write Documentation ~~~~~~~~~~~~~~~~~~~ qtpynodeeditor could always use more documentation, whether as part of the official qtpynodeeditor docs, in docstrings, or even on the web in blog posts, articles, and such. Submit Feedback ~~~~~~~~~~~~~~~ The best way to send feedback is to file an issue at https://github.com/klauer/qtpynodeeditor/issues. If you are proposing a feature: * Explain in detail how it would work. * Keep the scope as narrow as possible, to make it easier to implement. * Remember that this is a volunteer-driven project, and that contributions are welcome :) Get Started! ------------ Ready to contribute? Here's how to set up `qtpynodeeditor` for local development. 1. Fork the `qtpynodeeditor` repo on GitHub. 2. Clone your fork locally:: $ git clone git@github.com:your_name_here/qtpynodeeditor.git 3. Install your local copy into a virtualenv. Assuming you have virtualenvwrapper installed, this is how you set up your fork for local development:: $ mkvirtualenv qtpynodeeditor $ cd qtpynodeeditor/ $ python setup.py develop 4. Create a branch for local development:: $ git checkout -b name-of-your-bugfix-or-feature Now you can make your changes locally. 5. When you're done making changes, check that your changes pass flake8 and the tests, including testing other Python versions with tox:: $ flake8 qtpynodeeditor tests $ python setup.py test $ tox To get flake8 and tox, just pip install them into your virtualenv. 6. Commit your changes and push your branch to GitHub:: $ git add . $ git commit -m "Your detailed description of your changes." $ git push origin name-of-your-bugfix-or-feature 7. Submit a pull request through the GitHub website. Pull Request Guidelines ----------------------- Before you submit a pull request, check that it meets these guidelines: 1. The pull request should include tests. 2. If the pull request adds functionality, the docs should be updated. Put your new functionality into a function with a docstring, and add the feature to the list in README.rst. 3. The pull request should work for Python 3.6 and up. Check https://travis-ci.org/klauer/qtpynodeeditor/pull_requests and make sure that the tests pass for all supported Python versions. qtpynodeeditor-0.2.0/LICENSE000066400000000000000000000031511372375653600156110ustar00rootroot00000000000000Copyright (c) 2019, Ken Lauer All rights reserved. qtpynodeeditor is a derivative work of NodeEditor by Dmitry Pinaev. It follows in the footsteps of the original and is licensed by the BSD 3-clause license. Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met: * Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer. * Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution. * Neither the name of copyright holder, nor the names of its contributors may be used to endorse or promote products derived from this software without specific prior written permission. THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. qtpynodeeditor-0.2.0/MANIFEST.in000066400000000000000000000005371372375653600163470ustar00rootroot00000000000000include AUTHORS.rst include CONTRIBUTING.rst include LICENSE include README.rst include requirements.txt include dev-requirements.txt recursive-exclude * __pycache__ recursive-exclude * *.py[co] recursive-include docs *.rst conf.py Makefile make.bat include versioneer.py include qtpynodeeditor/_version.py include qtpynodeeditor/DefaultStyle.json qtpynodeeditor-0.2.0/README.rst000066400000000000000000000031241372375653600162730ustar00rootroot00000000000000.. image:: https://img.shields.io/travis/klauer/qtpynodeeditor.svg :target: https://travis-ci.org/klauer/qtpynodeeditor .. image:: https://img.shields.io/pypi/v/qtpynodeeditor.svg :target: https://pypi.python.org/pypi/qtpynodeeditor =============================== qtpynodeeditor =============================== Python Qt node editor Pure Python port of `NodeEditor `_, supporting PyQt5 and PySide through `qtpy `_. Requirements ------------ * Python 3.6+ * qtpy * PyQt5 / PySide Documentation ------------- `Sphinx-generated documentation `_ Screenshots ----------- `Style example `_ .. image:: https://raw.githubusercontent.com/klauer/qtpynodeeditor/assets/screenshots/style.png `Calculator example `_ .. image:: https://raw.githubusercontent.com/klauer/qtpynodeeditor/assets/screenshots/calculator.png Installation ------------ We recommend using conda to install qtpynodeeditor. :: $ conda create -n my_new_environment -c conda-forge python=3.7 qtpynodeeditor $ conda activate my_new_environment qtpynodeeditor may also be installed using pip from PyPI. :: $ python -m pip install qtpynodeeditor pyqt5 Running the Tests ----------------- Tests must be run with pytest and pytest-qt. :: $ pip install -r dev-requirements.txt $ pytest -v qtpynodeeditor/tests qtpynodeeditor-0.2.0/conda-recipe/000077500000000000000000000000001372375653600171355ustar00rootroot00000000000000qtpynodeeditor-0.2.0/conda-recipe/build.sh000066400000000000000000000001211372375653600205620ustar00rootroot00000000000000$PYTHON setup.py install --single-version-externally-managed --record=record.txt qtpynodeeditor-0.2.0/conda-recipe/meta.yaml000066400000000000000000000007431372375653600207530ustar00rootroot00000000000000{% set data = load_setup_py_data() %} build: noarch: python package: name : qtpynodeeditor version : {{ data.get('version') }} source: path: .. requirements: build: - python >=3.6 - setuptools run: - python >=3.6 - pyqt >=5 - qtpy test: imports: - qtpynodeeditor requires: - pytest about: home: https://github.com/klauer/qtpynodeeditor license: BSD 3-clause summary: Python Qt node editor qtpynodeeditor-0.2.0/dev-requirements.txt000066400000000000000000000005151372375653600206450ustar00rootroot00000000000000# These are required for developing the package (running the tests, building # the documentation) but not necessarily required for _using_ it. codecov coverage flake8 pytest pytest-qt sphinx doctr # These are dependencies of various sphinx extensions for documentation. ipython matplotlib numpydoc sphinx-copybutton sphinx_rtd_theme qtpynodeeditor-0.2.0/docs/000077500000000000000000000000001372375653600155345ustar00rootroot00000000000000qtpynodeeditor-0.2.0/docs/Makefile000066400000000000000000000011501372375653600171710ustar00rootroot00000000000000# Minimal makefile for Sphinx documentation # # You can set these variables from the command line. SPHINXOPTS = SPHINXBUILD = sphinx-build SPHINXPROJ = qtpynodeeditor SOURCEDIR = source BUILDDIR = build # Put it first so that "make" without argument is like "make help". help: @$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) .PHONY: help Makefile # Catch-all target: route all unknown targets to Sphinx using the new # "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS). %: Makefile @$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) qtpynodeeditor-0.2.0/docs/make.bat000066400000000000000000000014221372375653600171400ustar00rootroot00000000000000@ECHO OFF pushd %~dp0 REM Command file for Sphinx documentation if "%SPHINXBUILD%" == "" ( set SPHINXBUILD=sphinx-build ) set SOURCEDIR=source set BUILDDIR=build set SPHINXPROJ=qtpynodeeditor if "%1" == "" goto help %SPHINXBUILD% >NUL 2>NUL if errorlevel 9009 ( echo. echo.The 'sphinx-build' command was not found. Make sure you have Sphinx echo.installed, then set the SPHINXBUILD environment variable to point echo.to the full path of the 'sphinx-build' executable. Alternatively you echo.may add the Sphinx directory to PATH. echo. echo.If you don't have Sphinx installed, grab it from echo.http://sphinx-doc.org/ exit /b 1 ) %SPHINXBUILD% -M %1 %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% goto end :help %SPHINXBUILD% -M help %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% :end popd qtpynodeeditor-0.2.0/docs/source/000077500000000000000000000000001372375653600170345ustar00rootroot00000000000000qtpynodeeditor-0.2.0/docs/source/api.rst000066400000000000000000000047621372375653600203500ustar00rootroot00000000000000=== API === .. currentmodule:: qtpynodeeditor -------------- Scene and View -------------- FlowScene ========= .. autoclass:: FlowScene :members: FlowView ======== .. autoclass:: FlowView :members: ------ Styles ------ StyleCollection =============== .. autoclass:: StyleCollection :members: Style ===== .. autoclass:: Style :members: ConnectionStyle =============== .. autoclass:: ConnectionStyle :members: FlowViewStyle ============= .. autoclass:: FlowViewStyle :members: NodeStyle ========= .. autoclass:: NodeStyle :members: ----- Nodes ----- Node ==== .. autoclass:: Node :members: NodeData ======== .. autoclass:: NodeData :members: NodeDataModel ============= .. autoclass:: NodeDataModel :members: NodeState ========= .. autoclass:: NodeState :members: NodeDataType ============ .. autoclass:: NodeDataType :members: NodeGeometry ============ .. autoclass:: NodeGeometry :members: NodeGraphicsObject ================== .. autoclass:: NodeGraphicsObject :members: DataModelRegistry ================= .. autoclass:: DataModelRegistry :members: ----------- Connections ----------- Connection ========== .. autoclass:: Connection :members: ConnectionGeometry ================== .. autoclass:: ConnectionGeometry :members: ConnectionGraphicsObject ======================== .. autoclass:: ConnectionGraphicsObject :members: Exceptions ========== .. autoclass:: NodeConnectionFailure .. autoclass:: ConnectionCycleFailure .. autoclass:: ConnectionDataTypeFailure .. autoclass:: ConnectionPointFailure .. autoclass:: ConnectionPortNotEmptyFailure .. autoclass:: ConnectionRequiresPortFailure .. autoclass:: ConnectionSelfFailure .. autoclass:: MultipleInputConnectionError .. autoclass:: PortsAlreadyConnectedError .. autoclass:: PortsOfSameTypeError ----- Ports ----- Port ==== .. autoclass:: Port :members: PortType ======== .. autoclass:: PortType :members: ---------------- Other / Internal ---------------- ConnectionPainter ================= .. autoclass:: ConnectionPainter :members: ConnectionPolicy ================ .. autoclass:: ConnectionPolicy :members: NodeConnectionInteraction ========================= .. autoclass:: NodeConnectionInteraction :members: NodePainter =========== .. autoclass:: NodePainter :members: NodePainterDelegate =================== .. autoclass:: NodePainterDelegate :members: NodeValidationState =================== .. autoclass:: NodeValidationState :members: qtpynodeeditor-0.2.0/docs/source/conf.py000066400000000000000000000123161372375653600203360ustar00rootroot00000000000000# -*- coding: utf-8 -*- # # Configuration file for the Sphinx documentation builder. # # This file does only contain a selection of the most common options. For a # full list see the documentation: # http://www.sphinx-doc.org/en/master/config # -- Path setup -------------------------------------------------------------- # If extensions (or modules to document with autodoc) are in another directory, # add these directories to sys.path here. If the directory is relative to the # documentation root, use os.path.abspath to make it absolute, like shown here. # import os import sys import sphinx_rtd_theme module_path = os.path.join(os.path.dirname(os.path.abspath(__file__)),'../../') sys.path.insert(0,module_path) # -- Project information ----------------------------------------------------- project = 'qtpynodeeditor' copyright = '2019, Ken Lauer' author = 'Ken Lauer' # The short X.Y version version = '' # The full version, including alpha/beta/rc tags release = '' # -- General configuration --------------------------------------------------- # If your documentation needs a minimal Sphinx version, state it here. # # needs_sphinx = '1.0' # Add any Sphinx extension module names here, as strings. They can be # extensions coming with Sphinx (named 'sphinx.ext.*') or your custom # ones. extensions = [ 'sphinx.ext.autodoc', 'sphinx.ext.todo', 'sphinx.ext.coverage', 'sphinx.ext.mathjax', 'sphinx.ext.viewcode', 'sphinx.ext.githubpages', 'numpydoc', ] # Add any paths that contain templates here, relative to this directory. templates_path = ['_templates'] autosummary_generate = True numpydoc_show_class_members = False # The suffix(es) of source filenames. # You can specify multiple suffix as a list of string: # # source_suffix = ['.rst', '.md'] source_suffix = '.rst' # The master toctree document. master_doc = 'index' # The language for content autogenerated by Sphinx. Refer to documentation # for a list of supported languages. # # This is also used if you do content translation via gettext catalogs. # Usually you set "language" from the command line for these cases. language = None # List of patterns, relative to source directory, that match files and # directories to ignore when looking for source files. # This pattern also affects html_static_path and html_extra_path . exclude_patterns = [] # The name of the Pygments (syntax highlighting) style to use. pygments_style = 'sphinx' # -- Options for HTML output ------------------------------------------------- # The theme to use for HTML and HTML Help pages. See the documentation for # a list of builtin themes. # html_theme = 'sphinx_rtd_theme' # Theme options are theme-specific and customize the look and feel of a theme # further. For a list of options available for each theme, see the # documentation. # # html_theme_options = {} # Add any paths that contain custom static files (such as style sheets) here, # relative to this directory. They are copied after the builtin static files, # so a file named "default.css" will overwrite the builtin "default.css". html_static_path = ['_static'] # Custom sidebar templates, must be a dictionary that maps document names # to template names. # # The default sidebars (for documents that don't match any pattern) are # defined by theme itself. Builtin themes are using these templates by # default: ``['localtoc.html', 'relations.html', 'sourcelink.html', # 'searchbox.html']``. # # html_sidebars = {} # -- Options for HTMLHelp output --------------------------------------------- # Output file base name for HTML help builder. htmlhelp_basename = 'qtpynodeeditor' # -- Options for LaTeX output ------------------------------------------------ latex_elements = { # The paper size ('letterpaper' or 'a4paper'). # # 'papersize': 'letterpaper', # The font size ('10pt', '11pt' or '12pt'). # # 'pointsize': '10pt', # Additional stuff for the LaTeX preamble. # # 'preamble': '', # Latex figure (float) alignment # # 'figure_align': 'htbp', } # Grouping the document tree into LaTeX files. List of tuples # (source start file, target name, title, # author, documentclass [howto, manual, or own class]). latex_documents = [ (master_doc, 'qtpynodeeditor.tex', 'qtpynodeeditor Documentation', 'Ken Lauer', 'manual'), ] # -- Options for manual page output ------------------------------------------ # One entry per manual page. List of tuples # (source start file, name, description, authors, manual section). man_pages = [ (master_doc, 'qtpynodeeditor', 'qtpynodeeditor Documentation', [author], 1) ] # -- Options for Texinfo output ---------------------------------------------- # Grouping the document tree into Texinfo files. List of tuples # (source start file, target name, title, author, # dir menu entry, description, category) texinfo_documents = [ (master_doc, 'qtpynodeeditor', 'qtpynodeeditor Documentation', author, 'qtpynodeeditor', 'Python Qt Node Editor', 'Miscellaneous'), ] # -- Extension configuration ------------------------------------------------- # -- Options for todo extension ---------------------------------------------- # If true, `todo` and `todoList` produce output, else they produce nothing. todo_include_todos = True qtpynodeeditor-0.2.0/docs/source/getting_started.rst000066400000000000000000000027421372375653600227620ustar00rootroot00000000000000Getting Started =============== Requirements ------------ * Python 3.6+ * qtpy * PyQt5 / PySide Installation ------------ We recommend using conda to install qtpynodeeditor. :: $ conda create -n my_new_environment -c conda-forge python=3.7 qtpynodeeditor $ conda activate my_new_environment qtpynodeeditor may also be installed using pip from PyPI. :: $ python -m pip install qtpynodeeditor pyqt5 Examples -------- 1. `Connection colors `_ :: $ python -m qtpynodeeditor.examples.connection_colors .. image:: https://raw.githubusercontent.com/klauer/qtpynodeeditor/assets/screenshots/connection_colors.png 2. `Image `_ :: $ python -m qtpynodeeditor.examples.image .. image:: https://raw.githubusercontent.com/klauer/qtpynodeeditor/assets/screenshots/image.png 3. `Style `_ :: $ python -m qtpynodeeditor.examples.style .. image:: https://raw.githubusercontent.com/klauer/qtpynodeeditor/assets/screenshots/style.png 4. `Calculator `_ :: $ python -m qtpynodeeditor.examples.calculator .. image:: https://raw.githubusercontent.com/klauer/qtpynodeeditor/assets/screenshots/calculator.png qtpynodeeditor-0.2.0/docs/source/index.rst000066400000000000000000000007561372375653600207050ustar00rootroot00000000000000============== qtpynodeeditor ============== A pure Python port of `NodeEditor `_. Contents ======== .. toctree:: :maxdepth: 3 :caption: API Documentation :hidden: getting_started.rst api.rst release_notes.rst .. toctree:: :maxdepth: 1 :caption: Links :hidden: Github Repository Indices and tables ================== * :ref:`genindex` * :ref:`modindex` * :ref:`search` qtpynodeeditor-0.2.0/docs/source/release_notes.rst000066400000000000000000000034661372375653600224270ustar00rootroot00000000000000================= Release History ================= v0.2.0 (2020-09-02) =================== Enhancements ------------ * Verify connection compatibility with :class:`~qtpynodeeditor.NodeDataType` (`#43 `_) Fixes ----- * Do not allow for cyclic connections in the scene (`#35 `_) * :class:`~qtpynodeeditor.NodeDataModel` ``input_connection_created`` now only called once (`#27 `_) * Incorrect connections in calculator example (`#38 `_) * Fix filename globbing in open/save file dialogs. API Changes ----------- * :class:`~qtpynodeeditor.Connection` property ``output_node`` should be used in favor of the now-deprecated ``node`` property. * New connection failure exceptions: :class:`~qtpynodeeditor.ConnectionCycleFailure` and :class:`~qtpynodeeditor.ConnectionDataTypeFailure`. * Fixed deprecated ``QFontMetrics.width``. Contributors ------------ * @tfarago (`#43 `_, `#28 `_) Thanks to those who reported issues and contributed to this release. v0.1.0 (2020-03-29) =================== Now available on conda-forge:: conda install -c conda-forge qtpynodeeditor Fixes ----- * Packaging of style configuration Development ----------- * Testing and supporting pyqt5 / PySide2 * Miscellaneous cleaning and fixing, along with better continuous integration testing API Changes ----------- * New signature for ``node_context_menu`` signal: ``(node, scene_pos, screen_pos)``. v0.0.1 (2020-03-29) =================== Initial test release of qtpynodeeditor. Now available on PyPI:: pip install qtpynodeeditor qtpynodeeditor-0.2.0/github_deploy_key_klauer_qtpynodeeditor.enc000066400000000000000000000105441372375653600255020ustar00rootroot00000000000000gAAAAABclmwX_kMtiqoM4z69uaJF7YAgCDRc1EvLbYcI-Zt40TpXO0wM6WzYzXp1AdPZ_-PhekDYQ9eJguTgwG738y3VCCnYV5tmnwYRVKUJlRr0fsWtuUnq_k6nFzfcXDD5r6H7ugElEjjDecbubM3RGt3Gp95mL_4a0c4avNAr7tSyLGCc_Fn1gOo0EaSVzSpOiviCBfEMnPLyQoSXZWXGcUqyqAQfd7tmq-tHu1yQupmgZMBYolJJrp5-GNIxsKNMpd43HKsn4Hx9NW5cKRKdzr_kPE_WDBZPUjcV5YUYtZ4OQN-rLrufdMz0R1U6bvwiLdIHsTtd_Ei3GVxLkVTWq-67VAah07JUXQHua4WYNtvfRC3RKmFRuBhWAUaZYA5Jg0rm8ibeqtVapaDa5vplAJLKJmW-_i4WuYh2d-xwOxDR1ugUW3rBRYu_e4Tgf6vdFooGZoWklx2myzdaKwBpbv85eydywYI0oD4bhiDUyRSIKqQssiqgLXZDGoso1N5w8ARl56O68mjNc7Cq73-j85vXlhND6JwQiF3krtbReGNw0Nhxovlr08S1_cxwnxYBr69TDCvsPumaZintcozjFj5DRPyF6xpWFwKy1IKp7SykZP4QoimOlsE4hUu3InJuod7t0dBRACfkPCJ654cXq-iKdHlHx41GntMenGEp4WFOzaTP_HPs0BVXY23aw1uX0NeDnMO5ax2pF5mTZ0iEiz6CzjVqwg7fkGh0lTMHyscgQ_MT7XYJeTvoNRgyKP5ttRL7lnVQbsK736so7EB_2Yp3vNMhqxBXkw3U64XCzF7sVGmSpa8fNBfD3pNcBLXTcz6wLKVfYuXkn_KM8AAf66qZbhpeQW1SeQNZ4QyR1TmiW59xjolDSf6CUU5R7RfyHT_rs8-G9yUncQN7xm8MpJ4gBEeNqXO_JLvA0PkSiAIklkpH9yLjykGvlRy4IdRcsyVPHMNN_rQ2Dxq0In_9D0LqWm3qonvb36HrrAHOut89PkxSp3bRgrfirJmfOFisXaxyipEWaoDF3owjemECJZr305sCm_B-N5WiXDJUZ-nxCKBRVKi4ee4jOmSF0WWRAQz6EZLaUXmK3gs1gmZSWTCndBYnDKYRV7tGKeXtOdUFmWYo5beWA4YQFHA661_9M4wJHB2qVSEP7MLbpCW0vml6Z48GB71JeB0ZAFG9S7wZ_UqXIoq8-aa0dD1VIEpWwASSg80G2xKHzAsQsm45ay0qlW8bEgBCcJS7UOu0UtyYN-AjHDLmj0mv-xtqb7b8LTryUPPQd6wwFxY77qmI0Vq688qFGdrADtcbuT2QxtOW0RaXxXONQBo5ryySwhj-cNEtjC3Rl181LqflN-m9Vxt-NjyI7pmB4FL5ELS_1elifShypIP7gig1IZVYqt9o1bboaszI5kN2fWglW0T9q0YVgjn4dgt1335uRmk004zLHmm2mGur06BxhSDlBFe5FygD9Kgk7hBMC38bpAd2hR2M6Cls73bX0hwKnmAqTLvGgGyIdo1gvI04Q9RpzIQeKAzYgv8cmFNjJIaDzmd3X-vHf0m_zQX_nz0RBT6xJCb_KvLV3Ova7plJDgoRiNsd5lOQiwrpmd9u7fwq9FlitKZlqgT6iuV9IR2eD2317Aj96bc-kNtFYB0JmMxeNBzoml4MkI8onw6dEoWDf7RlcYWRBcjoBlaJP5oGQM8_VjLvTDmc8z0-2h_cJ59CbPEKfcGpAbYsT21smv_N-KsSchs1AB2ZGSWECDhg6eREe_w5-Bd1ARCHRyppWQCd2nW1lfCQujD_AC5X94LQcOuoZSaRot4T7836bI6U7xndom_B6mnBBUpxsx6c3lLOy7fikBNWtfBvhYMJ3-aEdDJVGzETFv_Q0Yiu8UzqSZf2vTksrYhIXqhBxUKfXbLyNtltQ7J8voqZMPvE6aQxkGdcsrSBtwDP0KsTvxGv7SVf7eMaOVVHNZ62oG-0gYBEjviANZ3tv4uVsflGG5SwnAcAYDKztRdZvb3nO73Q03UbvgcgSGpopQkLbQr1AiSkQd6koYvFNmeQQPOiLCyV2KQqapWK3cwYsWrXvhY4MRY_uWX3buSxTQfFS9aNhxoR6JJgCLM3HFvuAAchXznkzwhWNfrzJiBy9R7qHrF3bafbHsbtYgFU6TYdgeVsOXPBtBE5okG_lEMyVndJzA6ShtI05CmgQcwTUMTH8faYgomig9H1jozJhrIZQAeF3oKoZmBlhSrrsLAXVXpayVtiSDjuTAGvBsD21hSC80AlLv1SWnfHdlCb1wXtLkFaBERLOAY3Js5MYnJ1qAl-HMXBLFJ3Of-dg8VOBw5dLLoaxqOou-EJ9rPo9D4GpzW7PKPB_S93P3NQF-7ZNqyCnKLbdqucAxoouNraU6T8doSC1df7s195DPrjG99RIPYYt58NFIc8X5M_oxI0KV46XODTvz7hIecddrDCKVBmkEJgN5HOJ7SAqkMnyB3Fs-tqwnxdcLsvZMxK1PtHpgymhXlBVB7PQbT4hcrDe6L-ouQQF_9MJumVzF1WleTPlHVII5sop27HJo2vn7gwC3l1In-PHKH1PA2vpY1HvFnXu4spCL5Vj17veg-Uy0tiykpH7qiBqRn0pYt6E6nKKWov6G97WRzcZkCTAl2Beg6G4SqXthmDiYpGhq-oXbGo3ADrBKJHoTaCAAtyKDhFLncNZB_errHDXwe5kzT3DeUCBia2zzadOF_E1M2_54AhK9r3sZors8OZqdJSYS2R_Y-Ev_vr6UjKItRdnR6P8fNev2Yy6t_vHBetpeGJzztPFJJJmRohur5rZdYYqOvr_b5oiFeH-QLHjdsDKaQwgEunQS85mbCjeNlwaj8LNO6q_mluu34vxJY_CGT5CZJGduna6R9QQ4gUJYaLgP2ey5nl9glX0GGdRPx3QclCqKq83TEe4JyIqWpP9PGIiT8Bkpi0OoBJYYX-VzRqj8YtQ-njvSlQ_Hi1Y63bWUyRzdWX6jY9kh_jzmjFzZ_y-qD0pOARzXMi8HEfv22ROxOONczX7mxIjRU-u1HXUMNbYk4Fj8FtylZ9lzkld3iGaZJSH49G_2p2HcAWcmtI-oUqxiBs8PYaqNWj-KnawmOUZ6C7KtFk1nApXq2N0O2w8Z175wNzbzBGy34aYGOXi43JONeDf3meK9DLgyoU-NxnTyF8cRS7kDPbmgBXTOckWspjvmmD4ep3IVxiaFiOQFdAkpDjhOZZvTe9u__X0lUZ6LyYDEKpOK6x1zrERFN7M2Cux0bOTISLEx6wZPXX8pVYczrQwxDTE1AhP7GarYmxBIGnUVffhtOgJgsE7xtPiwycZrr3r_J0dabpIuAjpwkaFV5_kwk-lBQWpysmCs4J3I2m25NvjAe5yrtHC5PZwki5urtXzD-aIuqvk159P-hX3QFIVJZJq_Xro-fRic22WBND5tezUnZfLyg6s2nLo62IBqgbQFmL4yYjHLZ2s3K8Dz_wKmI-0KvLnROm1RqAuKBpdGqJbjurQjRMT71_4YnvYVx17AO1zEfR9bwQvaT8qeJiB6CUGjaiFGXE6gG3bse7n59osHA3eQErCTpXANP0MHMpzL6vUaRkiWlfSFTNAqCsbVUtNkcE0dAdGGx3tvKSnctopl9CIbwUl4yYsZ9sbFdOVcOJwSFcLd4t0sxZ2vrKTenXs7087y7cMwb3k9k8jtBRRSobIrlpbLHd-EFbCanaKPkKq3j8Utt_cI6wBcAOtQSbXvCegEFMxXgeieTJHvEqtgPe2a9tiA3y9JASRCjyt2nDUvCOOErB7At3qkYVklZlB6D44eued_x0Q6wTfhHdVKcGFgurh2JdKpWZfE9BC5mSsvQiXgyUpJZtGRmr1brcujY5ZC0tQQLB-5zljxbl5Z3Si7AfEw0ay7zflx79Jhrzce78hM82M8O6Cgke1cwQVYVhdO3ONSyg5B5Jm--HdjGMYCN2aXBEOdJ8Hqkc04jddAtWTscE0Dntd60z9_cQpfUlQ70GHEZVlkBFUxWiKwKFmBe2Ti_HK_rOTbfyIZgOZ25ZN3riajEBR2CfSNjJ3gaUpCiWnwKODq-j60dmO0R9axjAolrB77irPj8heUv1G3hYBqy6DTud9sL6fmoPDR8CI7Qgz1h9TQBgGA30TP26Ag70hx_egEuqFQuXFCVvKmWXadZecNgoAoRLAFE1uYLuIyRfzuBKDiMOe4o9MD4ANvAt8DUAPicGOZQNCA5LeujJHHcdYrNg6zmVKGhl36Fen70_3QQB9f4dHvCoyBunv5ttktmD87YdkHLfgew9CBGFxg9ThcIXEiOBXKQplNUoOkDr5U9lgg9eDSszQyqHBOCNSnuHwSFDraaCzalBHAuHp5KjrVzxjO-7fFaX6VIpXTHvCA1YvUuuf1t_T6PlYmNgLXfBdpz4uTDetnEDsW-reFRiT8S37g==qtpynodeeditor-0.2.0/qtpynodeeditor/000077500000000000000000000000001372375653600176565ustar00rootroot00000000000000qtpynodeeditor-0.2.0/qtpynodeeditor/DefaultStyle.json000066400000000000000000000020271372375653600231570ustar00rootroot00000000000000{ "FlowViewStyle": { "BackgroundColor": [53, 53, 53], "FineGridColor": [60, 60, 60], "CoarseGridColor": [25, 25, 25] }, "NodeStyle": { "NormalBoundaryColor": [255, 255, 255], "SelectedBoundaryColor": [255, 165, 0], "GradientColor0": "gray", "GradientColor1": [80, 80, 80], "GradientColor2": [64, 64, 64], "GradientColor3": [58, 58, 58], "ShadowColor": [20, 20, 20], "FontColor" : "white", "FontColorFaded" : "gray", "ConnectionPointColor": [169, 169, 169], "FilledConnectionPointColor": "cyan", "ErrorColor": "red", "WarningColor": [128, 128, 0], "PenWidth": 1.0, "HoveredPenWidth": 1.5, "ConnectionPointDiameter": 8.0, "Opacity": 0.8 }, "ConnectionStyle": { "ConstructionColor": "gray", "NormalColor": "darkcyan", "SelectedColor": [100, 100, 100], "SelectedHaloColor": "orange", "HoveredColor": "lightcyan", "LineWidth": 3.0, "ConstructionLineWidth": 2.0, "PointDiameter": 10.0, "UseDataDefinedColors": false } } qtpynodeeditor-0.2.0/qtpynodeeditor/__init__.py000066400000000000000000000041611372375653600217710ustar00rootroot00000000000000from .connection import Connection from .connection_geometry import ConnectionGeometry from .connection_graphics_object import ConnectionGraphicsObject from .connection_painter import ConnectionPainter from .data_model_registry import DataModelRegistry from .enums import ConnectionPolicy, NodeValidationState, PortType from .exceptions import (ConnectionCycleFailure, ConnectionDataTypeFailure, ConnectionPointFailure, ConnectionPortNotEmptyFailure, ConnectionRequiresPortFailure, ConnectionSelfFailure, MultipleInputConnectionError, NodeConnectionFailure, PortsAlreadyConnectedError, PortsOfSameTypeError) from .flow_scene import FlowScene from .flow_view import FlowView from .node import Node, NodeDataType from .node_connection_interaction import NodeConnectionInteraction from .node_data import NodeData, NodeDataModel from .node_geometry import NodeGeometry from .node_graphics_object import NodeGraphicsObject from .node_painter import NodePainter, NodePainterDelegate from .node_state import NodeState from .port import Port, opposite_port from .style import (ConnectionStyle, FlowViewStyle, NodeStyle, Style, StyleCollection) __all__ = [ 'Connection', 'ConnectionCycleFailure', 'ConnectionDataTypeFailure', 'ConnectionGeometry', 'ConnectionGraphicsObject', 'ConnectionPainter', 'ConnectionPointFailure', 'ConnectionPolicy', 'ConnectionPortNotEmptyFailure', 'ConnectionRequiresPortFailure', 'ConnectionSelfFailure', 'ConnectionStyle', 'DataModelRegistry', 'FlowScene', 'FlowView', 'FlowViewStyle', 'MultipleInputConnectionError', 'Node', 'NodeConnectionFailure', 'NodeConnectionInteraction', 'NodeData', 'NodeDataModel', 'NodeDataType', 'NodeGeometry', 'NodeGraphicsObject', 'NodePainter', 'NodePainterDelegate', 'NodeState', 'NodeStyle', 'NodeValidationState', 'Port', 'PortType', 'PortsAlreadyConnectedError', 'PortsOfSameTypeError', 'Style', 'StyleCollection', 'opposite_port', ] qtpynodeeditor-0.2.0/qtpynodeeditor/_version.py000066400000000000000000000440301372375653600220550ustar00rootroot00000000000000 # This file helps to compute a version number in source trees obtained from # git-archive tarball (such as those provided by githubs download-from-tag # feature). Distribution tarballs (built by setup.py sdist) and build # directories (produced by setup.py build) will contain a much shorter file # that just contains the computed version number. # This file is released into the public domain. Generated by # versioneer-0.18 (https://github.com/warner/python-versioneer) """Git implementation of _version.py.""" import errno import os import re import subprocess import sys def get_keywords(): """Get the keywords needed to look up the version information.""" # these strings will be replaced by git during git-archive. # setup.py/versioneer.py will grep for the variable names, so they must # each be defined on a line of their own. _version.py will just call # get_keywords(). git_refnames = "$Format:%d$" git_full = "$Format:%H$" git_date = "$Format:%ci$" keywords = {"refnames": git_refnames, "full": git_full, "date": git_date} return keywords class VersioneerConfig: """Container for Versioneer configuration parameters.""" def get_config(): """Create, populate and return the VersioneerConfig() object.""" # these strings are filled in when 'setup.py versioneer' creates # _version.py cfg = VersioneerConfig() cfg.VCS = "git" cfg.style = "pep440" cfg.tag_prefix = "v" cfg.parentdir_prefix = "None" cfg.versionfile_source = "qtpynodeeditor/_version.py" cfg.verbose = False return cfg class NotThisMethod(Exception): """Exception raised if a method is not valid for the current scenario.""" LONG_VERSION_PY = {} HANDLERS = {} def register_vcs_handler(vcs, method): # decorator """Decorator to mark a method as the handler for a particular VCS.""" def decorate(f): """Store f in HANDLERS[vcs][method].""" if vcs not in HANDLERS: HANDLERS[vcs] = {} HANDLERS[vcs][method] = f return f return decorate def run_command(commands, args, cwd=None, verbose=False, hide_stderr=False, env=None): """Call the given command(s).""" assert isinstance(commands, list) p = None for c in commands: try: dispcmd = str([c] + args) # remember shell=False, so use git.cmd on windows, not just git p = subprocess.Popen([c] + args, cwd=cwd, env=env, stdout=subprocess.PIPE, stderr=(subprocess.PIPE if hide_stderr else None)) break except EnvironmentError: e = sys.exc_info()[1] if e.errno == errno.ENOENT: continue if verbose: print("unable to run %s" % dispcmd) print(e) return None, None else: if verbose: print("unable to find command, tried %s" % (commands,)) return None, None stdout = p.communicate()[0].strip() if sys.version_info[0] >= 3: stdout = stdout.decode() if p.returncode != 0: if verbose: print("unable to run %s (error)" % dispcmd) print("stdout was %s" % stdout) return None, p.returncode return stdout, p.returncode def versions_from_parentdir(parentdir_prefix, root, verbose): """Try to determine the version from the parent directory name. Source tarballs conventionally unpack into a directory that includes both the project name and a version string. We will also support searching up two directory levels for an appropriately named parent directory """ rootdirs = [] for i in range(3): dirname = os.path.basename(root) if dirname.startswith(parentdir_prefix): return {"version": dirname[len(parentdir_prefix):], "full-revisionid": None, "dirty": False, "error": None, "date": None} else: rootdirs.append(root) root = os.path.dirname(root) # up a level if verbose: print("Tried directories %s but none started with prefix %s" % (str(rootdirs), parentdir_prefix)) raise NotThisMethod("rootdir doesn't start with parentdir_prefix") @register_vcs_handler("git", "get_keywords") def git_get_keywords(versionfile_abs): """Extract version information from the given file.""" # the code embedded in _version.py can just fetch the value of these # keywords. When used from setup.py, we don't want to import _version.py, # so we do it with a regexp instead. This function is not used from # _version.py. keywords = {} try: f = open(versionfile_abs, "r") for line in f.readlines(): if line.strip().startswith("git_refnames ="): mo = re.search(r'=\s*"(.*)"', line) if mo: keywords["refnames"] = mo.group(1) if line.strip().startswith("git_full ="): mo = re.search(r'=\s*"(.*)"', line) if mo: keywords["full"] = mo.group(1) if line.strip().startswith("git_date ="): mo = re.search(r'=\s*"(.*)"', line) if mo: keywords["date"] = mo.group(1) f.close() except EnvironmentError: pass return keywords @register_vcs_handler("git", "keywords") def git_versions_from_keywords(keywords, tag_prefix, verbose): """Get version information from git keywords.""" if not keywords: raise NotThisMethod("no keywords at all, weird") date = keywords.get("date") if date is not None: # git-2.2.0 added "%cI", which expands to an ISO-8601 -compliant # datestamp. However we prefer "%ci" (which expands to an "ISO-8601 # -like" string, which we must then edit to make compliant), because # it's been around since git-1.5.3, and it's too difficult to # discover which version we're using, or to work around using an # older one. date = date.strip().replace(" ", "T", 1).replace(" ", "", 1) refnames = keywords["refnames"].strip() if refnames.startswith("$Format"): if verbose: print("keywords are unexpanded, not using") raise NotThisMethod("unexpanded keywords, not a git-archive tarball") refs = set([r.strip() for r in refnames.strip("()").split(",")]) # starting in git-1.8.3, tags are listed as "tag: foo-1.0" instead of # just "foo-1.0". If we see a "tag: " prefix, prefer those. TAG = "tag: " tags = set([r[len(TAG):] for r in refs if r.startswith(TAG)]) if not tags: # Either we're using git < 1.8.3, or there really are no tags. We use # a heuristic: assume all version tags have a digit. The old git %d # expansion behaves like git log --decorate=short and strips out the # refs/heads/ and refs/tags/ prefixes that would let us distinguish # between branches and tags. By ignoring refnames without digits, we # filter out many common branch names like "release" and # "stabilization", as well as "HEAD" and "master". tags = set([r for r in refs if re.search(r'\d', r)]) if verbose: print("discarding '%s', no digits" % ",".join(refs - tags)) if verbose: print("likely tags: %s" % ",".join(sorted(tags))) for ref in sorted(tags): # sorting will prefer e.g. "2.0" over "2.0rc1" if ref.startswith(tag_prefix): r = ref[len(tag_prefix):] if verbose: print("picking %s" % r) return {"version": r, "full-revisionid": keywords["full"].strip(), "dirty": False, "error": None, "date": date} # no suitable tags, so version is "0+unknown", but full hex is still there if verbose: print("no suitable tags, using unknown + full revision id") return {"version": "0+unknown", "full-revisionid": keywords["full"].strip(), "dirty": False, "error": "no suitable tags", "date": None} @register_vcs_handler("git", "pieces_from_vcs") def git_pieces_from_vcs(tag_prefix, root, verbose, run_command=run_command): """Get version from 'git describe' in the root of the source tree. This only gets called if the git-archive 'subst' keywords were *not* expanded, and _version.py hasn't already been rewritten with a short version string, meaning we're inside a checked out source tree. """ GITS = ["git"] if sys.platform == "win32": GITS = ["git.cmd", "git.exe"] out, rc = run_command(GITS, ["rev-parse", "--git-dir"], cwd=root, hide_stderr=True) if rc != 0: if verbose: print("Directory %s not under git control" % root) raise NotThisMethod("'git rev-parse --git-dir' returned error") # if there is a tag matching tag_prefix, this yields TAG-NUM-gHEX[-dirty] # if there isn't one, this yields HEX[-dirty] (no NUM) describe_out, rc = run_command(GITS, ["describe", "--tags", "--dirty", "--always", "--long", "--match", "%s*" % tag_prefix], cwd=root) # --long was added in git-1.5.5 if describe_out is None: raise NotThisMethod("'git describe' failed") describe_out = describe_out.strip() full_out, rc = run_command(GITS, ["rev-parse", "HEAD"], cwd=root) if full_out is None: raise NotThisMethod("'git rev-parse' failed") full_out = full_out.strip() pieces = {} pieces["long"] = full_out pieces["short"] = full_out[:7] # maybe improved later pieces["error"] = None # parse describe_out. It will be like TAG-NUM-gHEX[-dirty] or HEX[-dirty] # TAG might have hyphens. git_describe = describe_out # look for -dirty suffix dirty = git_describe.endswith("-dirty") pieces["dirty"] = dirty if dirty: git_describe = git_describe[:git_describe.rindex("-dirty")] # now we have TAG-NUM-gHEX or HEX if "-" in git_describe: # TAG-NUM-gHEX mo = re.search(r'^(.+)-(\d+)-g([0-9a-f]+)$', git_describe) if not mo: # unparseable. Maybe git-describe is misbehaving? pieces["error"] = ("unable to parse git-describe output: '%s'" % describe_out) return pieces # tag full_tag = mo.group(1) if not full_tag.startswith(tag_prefix): if verbose: fmt = "tag '%s' doesn't start with prefix '%s'" print(fmt % (full_tag, tag_prefix)) pieces["error"] = ("tag '%s' doesn't start with prefix '%s'" % (full_tag, tag_prefix)) return pieces pieces["closest-tag"] = full_tag[len(tag_prefix):] # distance: number of commits since tag pieces["distance"] = int(mo.group(2)) # commit: short hex revision ID pieces["short"] = mo.group(3) else: # HEX: no tags pieces["closest-tag"] = None count_out, rc = run_command(GITS, ["rev-list", "HEAD", "--count"], cwd=root) pieces["distance"] = int(count_out) # total number of commits # commit date: see ISO-8601 comment in git_versions_from_keywords() date = run_command(GITS, ["show", "-s", "--format=%ci", "HEAD"], cwd=root)[0].strip() pieces["date"] = date.strip().replace(" ", "T", 1).replace(" ", "", 1) return pieces def plus_or_dot(pieces): """Return a + if we don't already have one, else return a .""" if "+" in pieces.get("closest-tag", ""): return "." return "+" def render_pep440(pieces): """Build up version string, with post-release "local version identifier". Our goal: TAG[+DISTANCE.gHEX[.dirty]] . Note that if you get a tagged build and then dirty it, you'll get TAG+0.gHEX.dirty Exceptions: 1: no tags. git_describe was just HEX. 0+untagged.DISTANCE.gHEX[.dirty] """ if pieces["closest-tag"]: rendered = pieces["closest-tag"] if pieces["distance"] or pieces["dirty"]: rendered += plus_or_dot(pieces) rendered += "%d.g%s" % (pieces["distance"], pieces["short"]) if pieces["dirty"]: rendered += ".dirty" else: # exception #1 rendered = "0+untagged.%d.g%s" % (pieces["distance"], pieces["short"]) if pieces["dirty"]: rendered += ".dirty" return rendered def render_pep440_pre(pieces): """TAG[.post.devDISTANCE] -- No -dirty. Exceptions: 1: no tags. 0.post.devDISTANCE """ if pieces["closest-tag"]: rendered = pieces["closest-tag"] if pieces["distance"]: rendered += ".post.dev%d" % pieces["distance"] else: # exception #1 rendered = "0.post.dev%d" % pieces["distance"] return rendered def render_pep440_post(pieces): """TAG[.postDISTANCE[.dev0]+gHEX] . The ".dev0" means dirty. Note that .dev0 sorts backwards (a dirty tree will appear "older" than the corresponding clean one), but you shouldn't be releasing software with -dirty anyways. Exceptions: 1: no tags. 0.postDISTANCE[.dev0] """ if pieces["closest-tag"]: rendered = pieces["closest-tag"] if pieces["distance"] or pieces["dirty"]: rendered += ".post%d" % pieces["distance"] if pieces["dirty"]: rendered += ".dev0" rendered += plus_or_dot(pieces) rendered += "g%s" % pieces["short"] else: # exception #1 rendered = "0.post%d" % pieces["distance"] if pieces["dirty"]: rendered += ".dev0" rendered += "+g%s" % pieces["short"] return rendered def render_pep440_old(pieces): """TAG[.postDISTANCE[.dev0]] . The ".dev0" means dirty. Eexceptions: 1: no tags. 0.postDISTANCE[.dev0] """ if pieces["closest-tag"]: rendered = pieces["closest-tag"] if pieces["distance"] or pieces["dirty"]: rendered += ".post%d" % pieces["distance"] if pieces["dirty"]: rendered += ".dev0" else: # exception #1 rendered = "0.post%d" % pieces["distance"] if pieces["dirty"]: rendered += ".dev0" return rendered def render_git_describe(pieces): """TAG[-DISTANCE-gHEX][-dirty]. Like 'git describe --tags --dirty --always'. Exceptions: 1: no tags. HEX[-dirty] (note: no 'g' prefix) """ if pieces["closest-tag"]: rendered = pieces["closest-tag"] if pieces["distance"]: rendered += "-%d-g%s" % (pieces["distance"], pieces["short"]) else: # exception #1 rendered = pieces["short"] if pieces["dirty"]: rendered += "-dirty" return rendered def render_git_describe_long(pieces): """TAG-DISTANCE-gHEX[-dirty]. Like 'git describe --tags --dirty --always -long'. The distance/hash is unconditional. Exceptions: 1: no tags. HEX[-dirty] (note: no 'g' prefix) """ if pieces["closest-tag"]: rendered = pieces["closest-tag"] rendered += "-%d-g%s" % (pieces["distance"], pieces["short"]) else: # exception #1 rendered = pieces["short"] if pieces["dirty"]: rendered += "-dirty" return rendered def render(pieces, style): """Render the given version pieces into the requested style.""" if pieces["error"]: return {"version": "unknown", "full-revisionid": pieces.get("long"), "dirty": None, "error": pieces["error"], "date": None} if not style or style == "default": style = "pep440" # the default if style == "pep440": rendered = render_pep440(pieces) elif style == "pep440-pre": rendered = render_pep440_pre(pieces) elif style == "pep440-post": rendered = render_pep440_post(pieces) elif style == "pep440-old": rendered = render_pep440_old(pieces) elif style == "git-describe": rendered = render_git_describe(pieces) elif style == "git-describe-long": rendered = render_git_describe_long(pieces) else: raise ValueError("unknown style '%s'" % style) return {"version": rendered, "full-revisionid": pieces["long"], "dirty": pieces["dirty"], "error": None, "date": pieces.get("date")} def get_versions(): """Get version information or return default if unable to do so.""" # I am in _version.py, which lives at ROOT/VERSIONFILE_SOURCE. If we have # __file__, we can work backwards from there to the root. Some # py2exe/bbfreeze/non-CPython implementations don't do __file__, in which # case we can only use expanded keywords. cfg = get_config() verbose = cfg.verbose try: return git_versions_from_keywords(get_keywords(), cfg.tag_prefix, verbose) except NotThisMethod: pass try: root = os.path.realpath(__file__) # versionfile_source is the relative path from the top of the source # tree (where the .git directory might live) to this file. Invert # this to find the root from __file__. for i in cfg.versionfile_source.split('/'): root = os.path.dirname(root) except NameError: return {"version": "0+unknown", "full-revisionid": None, "dirty": None, "error": "unable to find root of source tree", "date": None} try: pieces = git_pieces_from_vcs(cfg.tag_prefix, root, verbose) return render(pieces, cfg.style) except NotThisMethod: pass try: if cfg.parentdir_prefix: return versions_from_parentdir(cfg.parentdir_prefix, root, verbose) except NotThisMethod: pass return {"version": "0+unknown", "full-revisionid": None, "dirty": None, "error": "unable to compute version", "date": None} qtpynodeeditor-0.2.0/qtpynodeeditor/base.py000066400000000000000000000006571372375653600211520ustar00rootroot00000000000000class NodeBase: ... class ConnectionBase: ... class FlowSceneBase: ... class Serializable: 'Interface for a serializable class' def save(self) -> dict: """ Save Returns ------- value : dict """ ... def restore(self, state: dict): """ Restore Parameters ---------- state : dict """ ... qtpynodeeditor-0.2.0/qtpynodeeditor/connection.py000066400000000000000000000262161372375653600223760ustar00rootroot00000000000000import uuid from qtpy.QtCore import QObject, Signal from . import exceptions from .base import ConnectionBase, Serializable from .connection_geometry import ConnectionGeometry from .connection_graphics_object import ConnectionGraphicsObject from .node import Node, NodeDataType from .node_data import NodeData from .port import Port, PortType, opposite_port from .style import StyleCollection from .type_converter import TypeConverter class Connection(QObject, Serializable, ConnectionBase): connection_completed = Signal(QObject) connection_made_incomplete = Signal(QObject) updated = Signal(QObject) def __init__(self, port_a: Port, port_b: Port = None, *, style: StyleCollection, converter: TypeConverter = None): super().__init__() self._uid = str(uuid.uuid4()) if port_a is None: raise ValueError('port_a is required') elif port_a is port_b: raise ValueError('Cannot connect a port to itself') if port_a.port_type == PortType.input: in_port = port_a out_port = port_b else: in_port = port_b out_port = port_a if in_port is not None and out_port is not None: if in_port.port_type == out_port.port_type: raise exceptions.PortsOfSameTypeError( 'Cannot connect two ports of the same type') self._ports = { PortType.input: in_port, PortType.output: out_port } if in_port is not None: if in_port.connections: conn, = in_port.connections existing_in, existing_out = conn.ports if existing_in == in_port and existing_out == out_port: raise exceptions.PortsAlreadyConnectedError( 'Specified ports already connected') raise exceptions.MultipleInputConnectionError( f'Maximum one connection per input port ' f'(existing: {conn})') if in_port and out_port: self._required_port = PortType.none elif in_port: self._required_port = PortType.output else: self._required_port = PortType.input self._last_hovered_node = None self._converter = converter self._style = style self._connection_geometry = ConnectionGeometry(style) self._graphics_object = None def _cleanup(self): if self.is_complete: self.connection_made_incomplete.emit(self) self.propagate_empty_data() self.last_hovered_node = None for port_type, port in self.valid_ports.items(): if port.node.graphics_object is not None: port.node.graphics_object.update() self._ports[port] = None if self._graphics_object is not None: self._graphics_object._cleanup() self._graphics_object = None @property def style(self) -> StyleCollection: return self._style def __getstate__(self) -> dict: """ save Returns ------- value : dict """ in_port, out_port = self.ports if not in_port and not out_port: return {} connection_json = dict( in_id=in_port.node.id, in_index=in_port.index, out_id=out_port.node.id, out_index=out_port.index, ) if self._converter: def get_type_json(type: PortType): node_type = self.data_type(type) return dict( id=node_type.id, name=node_type.name ) connection_json["converter"] = { "in": get_type_json(PortType.input), "out": get_type_json(PortType.output), } return connection_json @property def id(self) -> str: """ Unique identifier (uuid) Returns ------- uuid : str """ return self._uid @property def required_port(self) -> PortType: """ Required port Returns ------- value : PortType """ return self._required_port @required_port.setter def required_port(self, dragging: PortType): """ Remembers the end being dragged. Invalidates Node address. Grabs mouse. Parameters ---------- dragging : PortType """ self._required_port = dragging try: port = self.valid_ports[dragging] except KeyError: ... else: port.remove_connection(self) @property def graphics_object(self) -> ConnectionGraphicsObject: """ Get the connection graphics object Returns ---------- graphics : ConnectionGraphicsObject """ return self._graphics_object @graphics_object.setter def graphics_object(self, graphics: ConnectionGraphicsObject): self._graphics_object = graphics # this function is only called when the ConnectionGraphicsObject is # newly created. At self moment both end coordinates are (0, 0) in # Connection G.O. coordinates. The position of the whole Connection GO # in scene coordinate system is also (0, 0). By moving the whole # object to the Node Port position we position both connection ends # correctly. if self.required_port != PortType.none: attached_port = opposite_port(self.required_port) attached_port_index = self.get_port_index(attached_port) node = self.get_node(attached_port) node_scene_transform = node.graphics_object.sceneTransform() pos = node.geometry.port_scene_position(attached_port, attached_port_index, node_scene_transform) self._graphics_object.setPos(pos) self._graphics_object.move() def connect_to(self, port: Port): """ Assigns a node to the required port. Parameters ---------- port : Port """ if self._ports[port.port_type] is not None: raise ValueError('Port already specified') was_incomplete = not self.is_complete self._ports[port.port_type] = port self.updated.emit(self) self.required_port = PortType.none if self.is_complete and was_incomplete: self.connection_completed.emit(self) def remove_from_nodes(self): for port in self._ports.values(): if port is not None: port.remove_connection(self) @property def geometry(self) -> ConnectionGeometry: """ Connection geometry Returns ------- value : ConnectionGeometry """ return self._connection_geometry def get_node(self, port_type: PortType) -> Node: """ Get node Parameters ---------- port_type : PortType Returns ------- value : Node """ port = self._ports[port_type] if port is not None: return port.node @property def nodes(self): # TODO namedtuple; TODO order return (self.get_node(PortType.input), self.get_node(PortType.output)) @property def ports(self): # TODO namedtuple; TODO order return (self._ports[PortType.input], self._ports[PortType.output]) def get_port_index(self, port_type: PortType) -> int: """ Get port index Parameters ---------- port_type : PortType Returns ------- index : int """ return self._ports[port_type].index def clear_node(self, port_type: PortType): """ Clear node Parameters ---------- port_type : PortType """ if self.is_complete: self.connection_made_incomplete.emit(self) port = self._ports[port_type] self._ports[port_type] = None port.remove_connection(self) @property def valid_ports(self): return {port_type: port for port_type, port in self._ports.items() if port is not None } def data_type(self, port_type: PortType) -> NodeDataType: """ Data type Parameters ---------- port_type : PortType Returns ------- value : NodeDataType """ ports = self.valid_ports if not ports: raise ValueError('No ports set') try: return ports[port_type].data_type except KeyError: valid_type, = ports return ports[valid_type].data_type @property def type_converter(self) -> TypeConverter: """ Set type converter Returns ------- converter : TypeConverter """ return self._converter @type_converter.setter def type_converter(self, converter: TypeConverter): self._converter = converter @property def is_complete(self) -> bool: """ Connection is complete - in/out nodes are set Returns ------- value : bool """ return all(self._ports.values()) def propagate_data(self, node_data: NodeData): """ Propagate the given data from the output port -> input port. Parameters ---------- node_data : NodeData """ in_port, out_port = self.ports if not in_port: return if node_data is not None and self._converter: node_data = self._converter(node_data) in_port.node.propagate_data(node_data, in_port) @property def input_node(self) -> Node: 'Input node' return self._ports[PortType.input].node @property def output_node(self) -> Node: 'Output node' return self._ports[PortType.output].node # For backward-compatibility: output = output_node def propagate_empty_data(self): self.propagate_data(None) @property def last_hovered_node(self) -> Node: """ Last hovered node Returns ------- value : Node """ return self._last_hovered_node @last_hovered_node.setter def last_hovered_node(self, node: Node): """ Set last hovered node Parameters ---------- node : Node """ if node is None and self._last_hovered_node: self._last_hovered_node.reset_reaction_to_connection() self._last_hovered_node = node def interact_with_node(self, node: Node): """ Interact with node Parameters ---------- node : Node """ self.last_hovered_node = node @property def requires_port(self) -> bool: """ Requires port Returns ------- value : bool """ return self._required_port != PortType.none def __repr__(self): return (f'<{self.__class__.__name__} ports={self._ports}>') qtpynodeeditor-0.2.0/qtpynodeeditor/connection_geometry.py000066400000000000000000000074111372375653600243050ustar00rootroot00000000000000from qtpy.QtCore import QPointF, QRectF from .port import PortType class ConnectionGeometry: def __init__(self, style): # local object coordinates self._in = QPointF(0, 0) self._out = QPointF(0, 0) # self._animationPhase = 0 self._line_width = 3.0 self._hovered = False self._point_diameter = style.connection.point_diameter def get_end_point(self, port_type: PortType) -> QPointF: """ Get end point Parameters ---------- port_type : PortType Returns ------- value : QPointF """ assert port_type != PortType.none return (self._out if port_type == PortType.output else self._in ) def set_end_point(self, port_type: PortType, point: QPointF): """ Set end point Parameters ---------- port_type : PortType point : QPointF """ if port_type == PortType.output: self._out = point elif port_type == PortType.input: self._in = point else: raise ValueError(port_type) def move_end_point(self, port_type: PortType, offset: QPointF): """ Move end point Parameters ---------- port_type : PortType offset : QPointF """ if port_type == PortType.output: self._out += offset elif port_type == PortType.input: self._in += offset else: raise ValueError(port_type) @property def bounding_rect(self) -> QRectF: """ Bounding rect Returns ------- value : QRectF """ c1, c2 = self.points_c1_c2() basic_rect = QRectF(self._out, self._in).normalized() c1c2_rect = QRectF(c1, c2).normalized() common_rect = basic_rect.united(c1c2_rect) corner_offset = QPointF(self._point_diameter, self._point_diameter) common_rect.setTopLeft(common_rect.topLeft() - corner_offset) common_rect.setBottomRight(common_rect.bottomRight() + 2 * corner_offset) return common_rect def points_c1_c2(self) -> tuple: """ Connection points (c1, c2) Returns ------- c1: QPointF The first point c2: QPointF The second point """ x_distance = self._in.x() - self._out.x() default_offset = 200.0 x_offset = min((default_offset, abs(x_distance))) y_offset = 0 x_ratio = 0.5 if x_distance <= 0: y_distance = self._in.y() - self._out.y() + 20 y_direction = (-1.0 if y_distance < 0 else 1.0) y_offset = y_direction * min((default_offset, abs(y_distance))) x_ratio = 1.0 x_offset *= x_ratio return ( QPointF(self._out.x() + x_offset, self._out.y() + y_offset), QPointF(self._in.x() - x_offset, self._in.y() - y_offset) ) @property def source(self) -> QPointF: """ Source Returns ------- value : QPointF """ return self._out @property def sink(self) -> QPointF: """ Sink Returns ------- value : QPointF """ return self._in def line_width(self) -> float: """ Line width Returns ------- value : double """ return self._line_width @property def hovered(self) -> bool: """ Hovered Returns ------- value : bool """ return self._hovered @hovered.setter def hovered(self, hovered: bool): self._hovered = hovered qtpynodeeditor-0.2.0/qtpynodeeditor/connection_graphics_object.py000066400000000000000000000152761372375653600256100ustar00rootroot00000000000000from qtpy.QtCore import QRectF from qtpy.QtGui import QPainter, QPainterPath from qtpy.QtWidgets import (QGraphicsBlurEffect, QGraphicsItem, QGraphicsObject, QGraphicsSceneHoverEvent, QGraphicsSceneMouseEvent, QStyleOptionGraphicsItem, QWidget) from .base import ConnectionBase from .connection_painter import ConnectionPainter from .node_connection_interaction import NodeConnectionInteraction from .port import PortType, opposite_port debug_drawing = False class ConnectionGraphicsObject(QGraphicsObject): def __init__(self, scene, connection): ''' connection_graphics_object Parameters ---------- scene : FlowScene connection : Connection ''' super().__init__() self._scene = scene self._connection = connection self._geometry = connection.geometry self._style = connection.style.connection self._scene.addItem(self) self.setFlag(QGraphicsItem.ItemIsMovable, True) self.setFlag(QGraphicsItem.ItemIsFocusable, True) self.setFlag(QGraphicsItem.ItemIsSelectable, True) self.setAcceptHoverEvents(True) # self.add_graphics_effect() self.setZValue(-1.0) def _cleanup(self): if self._scene is not None: self._scene.removeItem(self) self._scene = None @property def connection(self) -> ConnectionBase: """ Connection Returns ------- value : Connection """ return self._connection def boundingRect(self) -> QRectF: """ boundingRect Returns ------- value : QRectF """ return self._geometry.bounding_rect def shape(self) -> QPainterPath: """ Shape Returns ------- value : QPainterPath """ # TODO DEBUG_DRAWING if debug_drawing: path = QPainterPath() path.addRect(self.boundingRect()) return path return ConnectionPainter.get_painter_stroke(self._geometry) def set_geometry_changed(self): self.prepareGeometryChange() def move(self): """ Updates the position of both ends """ conn = self._connection cgo = conn.graphics_object for port_type in (PortType.input, PortType.output): node = self._connection.get_node(port_type) if node is None: continue node_graphics = node.graphics_object node_geom = node.geometry scene_pos = node_geom.port_scene_position( port_type, self._connection.get_port_index(port_type), node_graphics.sceneTransform() ) inverted, invertible = self.sceneTransform().inverted() if invertible: connection_pos = inverted.map(scene_pos) self._geometry.set_end_point(port_type, connection_pos) cgo.set_geometry_changed() cgo.update() def lock(self, locked: bool): """ Lock Parameters ---------- locked : bool """ self.setFlag(QGraphicsItem.ItemIsMovable, not locked) self.setFlag(QGraphicsItem.ItemIsFocusable, not locked) self.setFlag(QGraphicsItem.ItemIsSelectable, not locked) def paint(self, painter: QPainter, option: QStyleOptionGraphicsItem, widget: QWidget): """ Paint Parameters ---------- painter : QPainter option : QStyleOptionGraphicsItem widget : QWidget """ painter.setClipRect(option.exposedRect) ConnectionPainter.paint(painter, self._connection, self._style) def mousePressEvent(self, event: QGraphicsSceneMouseEvent): """ mousePressEvent Parameters ---------- event : QGraphicsSceneMouseEvent """ super().mousePressEvent(event) # event.ignore() def mouseMoveEvent(self, event: QGraphicsSceneMouseEvent): """ mouseMoveEvent Parameters ---------- event : QGraphicsSceneMouseEvent """ self.prepareGeometryChange() # view = event.widget() # TODO/BUG: widget is returning QWidget(), not QGraphicsView... view = self._scene.views()[0] node = self._scene.locate_node_at(event.scenePos(), view.transform()) self._connection.interact_with_node(node) state_required = self._connection.required_port if node: node.react_to_possible_connection( state_required, self._connection.data_type(opposite_port(state_required)), event.scenePos() ) # ------------------- offset = event.pos() - event.lastPos() required_port = self._connection.required_port if required_port != PortType.none: self._geometry.move_end_point(required_port, offset) # ------------------- self.update() event.accept() def mouseReleaseEvent(self, event: QGraphicsSceneMouseEvent): """ mouseReleaseEvent Parameters ---------- event : QGraphicsSceneMouseEvent """ self.ungrabMouse() event.accept() node = self._scene.locate_node_at(event.scenePos(), self._scene.views()[0].transform()) interaction = NodeConnectionInteraction(node, self._connection, self._scene) if node and interaction.try_connect(): node.reset_reaction_to_connection() if self._connection.requires_port: self._scene.delete_connection(self._connection) def hoverEnterEvent(self, event: QGraphicsSceneHoverEvent): """ hoverEnterEvent Parameters ---------- event : QGraphicsSceneHoverEvent """ self._geometry.hovered = True self.update() self._scene.connection_hovered.emit(self.connection, event.screenPos()) event.accept() def hoverLeaveEvent(self, event: QGraphicsSceneHoverEvent): """ hoverLeaveEvent Parameters ---------- event : QGraphicsSceneHoverEvent """ self._geometry.hovered = False self.update() self._scene.connection_hover_left.emit(self.connection) event.accept() def add_graphics_effect(self): effect = QGraphicsBlurEffect() effect.setBlurRadius(5) self.setGraphicsEffect(effect) # effect = QGraphicsDropShadowEffect() # effect = ConnectionBlurEffect(self) # effect.setOffset(4, 4) # effect.setColor(QColor(Qt.gray).darker(800)) qtpynodeeditor-0.2.0/qtpynodeeditor/connection_painter.py000066400000000000000000000131131372375653600241100ustar00rootroot00000000000000from qtpy.QtCore import QLineF, QPoint, QSize, Qt from qtpy.QtGui import QIcon, QPainter, QPainterPath, QPainterPathStroker, QPen from .base import ConnectionBase from .connection_geometry import ConnectionGeometry from .enums import PortType from .style import ConnectionStyle use_debug_drawing = False def cubic_path(geom): source, sink = geom.source, geom.sink c1, c2 = geom.points_c1_c2() # cubic spline cubic = QPainterPath(source) cubic.cubicTo(c1, c2, sink) return cubic def debug_drawing(painter, connection): geom = connection.geometry source, sink = geom.source, geom.sink c1, c2 = geom.points_c1_c2() painter.setPen(Qt.red) painter.setBrush(Qt.red) painter.drawLine(QLineF(source, c1)) painter.drawLine(QLineF(c1, c2)) painter.drawLine(QLineF(c2, sink)) painter.drawEllipse(c1, 3, 3) painter.drawEllipse(c2, 3, 3) painter.setBrush(Qt.NoBrush) painter.drawPath(cubic_path(geom)) painter.setPen(Qt.yellow) painter.drawRect(geom.bounding_rect) def draw_sketch_line(painter, connection, style): if not connection.requires_port: return p = QPen() p.setWidth(style.construction_line_width) p.setColor(style.construction_color) p.setStyle(Qt.DashLine) painter.setPen(p) painter.setBrush(Qt.NoBrush) geom = connection.geometry cubic = cubic_path(geom) # cubic spline painter.drawPath(cubic) def draw_hovered_or_selected(painter, connection, style): geom = connection.geometry hovered = geom.hovered graphics_object = connection.graphics_object selected = graphics_object.isSelected() # drawn as a fat background if hovered or selected: p = QPen() line_width = style.line_width p.setWidth(2 * line_width) p.setColor((style.selected_halo_color if selected else style.hovered_color)) painter.setPen(p) painter.setBrush(Qt.NoBrush) # cubic spline cubic = cubic_path(geom) painter.drawPath(cubic) def draw_normal_line(painter, connection, style): if connection.requires_port: return # colors normal_color_out = style.get_normal_color() normal_color_in = normal_color_out selected_color = style.selected_color gradient_color = False if style.use_data_defined_colors: data_type_out = connection.data_type(PortType.output) data_type_in = connection.data_type(PortType.input) gradient_color = data_type_out.id != data_type_in.id normal_color_out = style.get_normal_color(data_type_out.id) normal_color_in = style.get_normal_color(data_type_in.id) selected_color = normal_color_out.darker(200) # geometry geom = connection.geometry line_width = style.line_width # draw normal line p = QPen() p.setWidth(line_width) graphics_object = connection.graphics_object selected = graphics_object.isSelected() cubic = cubic_path(geom) if gradient_color: painter.setBrush(Qt.NoBrush) c = normal_color_out if selected: c = c.darker(200) p.setColor(c) painter.setPen(p) segments = 60 for i in range(segments): ratio_prev = float(i) / segments ratio = float(i + 1) / segments if i == segments / 2: c = normal_color_in if selected: c = c.darker(200) p.setColor(c) painter.setPen(p) painter.drawLine(cubic.pointAtPercent(ratio_prev), cubic.pointAtPercent(ratio)) icon = QIcon(":convert.png") pixmap = icon.pixmap(QSize(22, 22)) painter.drawPixmap(cubic.pointAtPercent(0.50) - QPoint(pixmap.width() / 2, pixmap.height() / 2), pixmap) else: p.setColor(normal_color_out) if selected: p.setColor(selected_color) painter.setPen(p) painter.setBrush(Qt.NoBrush) painter.drawPath(cubic) class ConnectionPainter: @staticmethod def paint(painter: QPainter, connection: ConnectionBase, style: ConnectionStyle): """ Paint Parameters ---------- painter : QPainter connection : Connection style : ConnectionStyle """ draw_hovered_or_selected(painter, connection, style) draw_sketch_line(painter, connection, style) draw_normal_line(painter, connection, style) if use_debug_drawing: debug_drawing(painter, connection) # draw end points geom = connection.geometry source, sink = geom.source, geom.sink point_diameter = style.point_diameter painter.setPen(style.construction_color) painter.setBrush(style.construction_color) point_radius = point_diameter / 2.0 painter.drawEllipse(source, point_radius, point_radius) painter.drawEllipse(sink, point_radius, point_radius) @staticmethod def get_painter_stroke(geom: ConnectionGeometry) -> QPainterPath: """ Get painter stroke Parameters ---------- geom : ConnectionGeometry Returns ------- value : QPainterPath """ cubic = cubic_path(geom) source = geom.source result = QPainterPath(source) segments = 20 for i in range(segments): ratio = float(i + 1) / segments result.lineTo(cubic.pointAtPercent(ratio)) stroker = QPainterPathStroker() stroker.setWidth(10.0) return stroker.createStroke(result) qtpynodeeditor-0.2.0/qtpynodeeditor/data_model_registry.py000066400000000000000000000066641372375653600242650ustar00rootroot00000000000000import logging import typing from .node_data import NodeDataModel, NodeDataType from .type_converter import TypeConverter logger = logging.getLogger(__name__) class DataModelRegistry: def __init__(self): self.type_converters = {} self._models_category = {} self._item_creators = {} self._categories = set() def register_model(self, creator, category='', *, style=None, **init_kwargs): name = creator.name self._item_creators[name] = (creator, {'style': style, **init_kwargs}) self._categories.add(category) self._models_category[name] = category def register_type_converter(self, type_in: NodeDataType, type_out: NodeDataType, type_converter: TypeConverter): """ Register type converter Parameters ---------- id_ : NodeData subclass or TypeConverterId type_converter : TypeConverter """ # TODO typing annotation if hasattr(type_in, 'type'): type_in = type_in.type if hasattr(type_out, 'type'): type_out = type_out.type self.type_converters[(type_in, type_out)] = type_converter def create(self, model_name: str) -> NodeDataModel: """ Create a :class:`NodeDataModel` given its user-friendly name. Parameters ---------- model_name : str Returns ------- data_model_instance : NodeDataModel The instance of the given data model. Raises ------ ValueError If the model name is not registered. """ cls, kwargs = self.get_model_by_name(model_name) return cls(**kwargs) def get_model_by_name(self, model_name: str ) -> typing.Tuple[typing.Type[NodeDataModel], dict]: """ Get information on how to create a specific :class:`NodeDataModel` node given its user-friendly name. Parameters ---------- model_name : str Returns ------- data_model : NodeDataModel The data model class. init_kwargs : dict Default init keyword arguments. Raises ------ ValueError If the model name is not registered. """ try: return self._item_creators[model_name] except KeyError: raise ValueError(f'Unknown model: {model_name}') from None def registered_model_creators(self) -> dict: """ Registered model creators Returns ------- value : dict """ return dict(self._item_creators) def registered_models_category_association(self) -> dict: """ Registered models category association Returns ------- value : DataModelRegistry.RegisteredModelsCategoryMap """ return self._models_category def categories(self) -> set: """ Categories Returns ------- value : DataModelRegistry.CategoriesSet """ return self._categories def get_type_converter(self, d1: NodeDataType, d2: NodeDataType) -> TypeConverter: """ Get type converter Parameters ---------- d1 : NodeDataType d2 : NodeDataType Returns ------- value : TypeConverter """ return self.type_converters.get((d1, d2), None) qtpynodeeditor-0.2.0/qtpynodeeditor/enums.py000066400000000000000000000006051372375653600213600ustar00rootroot00000000000000from enum import Enum class NodeValidationState(str, Enum): valid = 'valid' warning = 'warning' error = 'error' class PortType(str, Enum): none = 'none' input = 'input' output = 'output' class ConnectionPolicy(str, Enum): one = 'one' many = 'many' class ReactToConnectionState(str, Enum): reacting = 'reacting' not_reacting = 'not_reacting' qtpynodeeditor-0.2.0/qtpynodeeditor/examples/000077500000000000000000000000001372375653600214745ustar00rootroot00000000000000qtpynodeeditor-0.2.0/qtpynodeeditor/examples/__init__.py000066400000000000000000000001731372375653600236060ustar00rootroot00000000000000from . import calculator, connection_colors, image, style __all__ = ['calculator', 'style', 'connection_colors', 'image'] qtpynodeeditor-0.2.0/qtpynodeeditor/examples/calculator.py000066400000000000000000000304111372375653600241760ustar00rootroot00000000000000import contextlib import logging import threading from qtpy.QtGui import QDoubleValidator from qtpy.QtWidgets import QApplication, QLabel, QLineEdit, QWidget import qtpynodeeditor as nodeeditor from qtpynodeeditor.type_converter import TypeConverter from qtpynodeeditor import (NodeData, NodeDataModel, NodeDataType, NodeValidationState, Port, PortType) class DecimalData(NodeData): 'Node data holding a decimal (floating point) number' data_type = NodeDataType("decimal", "Decimal") def __init__(self, number: float = 0.0): self._number = number self._lock = threading.RLock() @property def lock(self): return self._lock @property def number(self) -> float: 'The number data' return self._number def number_as_text(self) -> str: 'Number as a string' return '%g' % self._number class IntegerData(NodeData): 'Node data holding an integer value' data_type = NodeDataType("integer", "Integer") def __init__(self, number: int = 0): self._number = number self._lock = threading.RLock() @property def lock(self): return self._lock @property def number(self) -> int: 'The number data' return self._number def number_as_text(self) -> str: 'Number as a string' return str(self._number) class MathOperationDataModel(NodeDataModel): caption_visible = True num_ports = { 'input': 2, 'output': 1, } port_caption_visible = True data_type = DecimalData.data_type def __init__(self, style=None, parent=None): super().__init__(style=style, parent=parent) self._number1 = None self._number2 = None self._result = None self._validation_state = NodeValidationState.warning self._validation_message = 'Uninitialized' @property def caption(self): return self.name def _check_inputs(self): number1_ok = (self._number1 is not None and self._number1.data_type.id in ('decimal', 'integer')) number2_ok = (self._number2 is not None and self._number2.data_type.id in ('decimal', 'integer')) if not number1_ok or not number2_ok: self._validation_state = NodeValidationState.warning self._validation_message = "Missing or incorrect inputs" self._result = None self.data_updated.emit(0) return False self._validation_state = NodeValidationState.valid self._validation_message = '' return True @contextlib.contextmanager def _compute_lock(self): if not self._number1 or not self._number2: raise RuntimeError('inputs unset') with self._number1.lock: with self._number2.lock: yield self.data_updated.emit(0) def out_data(self, port: int) -> NodeData: ''' The output data as a result of this calculation Parameters ---------- port : int Returns ------- value : NodeData ''' return self._result def set_in_data(self, data: NodeData, port: Port): ''' New data at the input of the node Parameters ---------- data : NodeData port_index : int ''' if port.index == 0: self._number1 = data elif port.index == 1: self._number2 = data if self._check_inputs(): with self._compute_lock(): self.compute() def validation_state(self) -> NodeValidationState: return self._validation_state def validation_message(self) -> str: return self._validation_message def compute(self): ... class AdditionModel(MathOperationDataModel): name = "Addition" def compute(self): self._result = DecimalData(self._number1.number + self._number2.number) class DivisionModel(MathOperationDataModel): name = "Division" port_caption = {'input': {0: 'Dividend', 1: 'Divisor', }, 'output': {0: 'Result'}, } def compute(self): if self._number2.number == 0.0: self._validation_state = NodeValidationState.error self._validation_message = "Division by zero error" self._result = None else: self._validation_state = NodeValidationState.valid self._validation_message = '' self._result = DecimalData(self._number1.number / self._number2.number) class ModuloModel(MathOperationDataModel): name = 'Modulo' data_type = IntegerData.data_type port_caption = {'input': {0: 'Dividend', 1: 'Divisor', }, 'output': {0: 'Result'}, } def compute(self): if self._number2.number == 0.0: self._validation_state = NodeValidationState.error self._validation_message = "Division by zero error" self._result = None else: self._result = IntegerData(self._number1.number % self._number2.number) class MultiplicationModel(MathOperationDataModel): name = 'Multiplication' port_caption = {'input': {0: 'A', 1: 'B', }, 'output': {0: 'Result'}, } def compute(self): self._result = DecimalData(self._number1.number * self._number2.number) class NumberSourceDataModel(NodeDataModel): name = "NumberSource" caption_visible = False num_ports = {PortType.input: 0, PortType.output: 1, } port_caption = {'output': {0: 'Result'}} data_type = DecimalData.data_type def __init__(self, style=None, parent=None): super().__init__(style=style, parent=parent) self._number = None self._line_edit = QLineEdit() self._line_edit.setValidator(QDoubleValidator()) self._line_edit.setMaximumSize(self._line_edit.sizeHint()) self._line_edit.textChanged.connect(self.on_text_edited) self._line_edit.setText("0.0") @property def number(self): return self._number def save(self) -> dict: 'Add to the JSON dictionary to save the state of the NumberSource' doc = super().save() if self._number: doc['number'] = self._number.number return doc def restore(self, state: dict): 'Restore the number from the JSON dictionary' try: value = float(state["number"]) except Exception: ... else: self._number = DecimalData(value) self._line_edit.setText(self._number.number_as_text()) def out_data(self, port: int) -> NodeData: ''' The data output from this node Parameters ---------- port : int Returns ------- value : NodeData ''' return self._number def embedded_widget(self) -> QWidget: 'The number source has a line edit widget for the user to type in' return self._line_edit def on_text_edited(self, string: str): ''' Line edit text has changed Parameters ---------- string : str ''' try: number = float(self._line_edit.text()) except ValueError: self._data_invalidated.emit(0) else: self._number = DecimalData(number) self.data_updated.emit(0) class NumberDisplayModel(NodeDataModel): name = "NumberDisplay" data_type = DecimalData.data_type caption_visible = False num_ports = {PortType.input: 1, PortType.output: 0, } port_caption = {'input': {0: 'Number'}} def __init__(self, style=None, parent=None): super().__init__(style=style, parent=parent) self._number = None self._label = QLabel() self._label.setMargin(3) self._validation_state = NodeValidationState.warning self._validation_message = 'Uninitialized' def set_in_data(self, data: NodeData, port: Port): ''' New data propagated to the input Parameters ---------- data : NodeData int : int ''' self._number = data number_ok = (self._number is not None and self._number.data_type.id in ('decimal', 'integer')) if number_ok: self._validation_state = NodeValidationState.valid self._validation_message = '' self._label.setText(self._number.number_as_text()) else: self._validation_state = NodeValidationState.warning self._validation_message = "Missing or incorrect inputs" self._label.clear() self._label.adjustSize() def embedded_widget(self) -> QWidget: 'The number display has a label' return self._label class SubtractionModel(MathOperationDataModel): name = "Subtraction" port_caption = {'input': {0: 'Minuend', 1: 'Subtrahend' }, 'output': {0: 'Result'}, } def compute(self): self._result = DecimalData(self._number1.number - self._number2.number) def integer_to_decimal_converter(data: IntegerData) -> DecimalData: ''' integer_to_decimal_converter Parameters ---------- data : NodeData Returns ------- value : NodeData ''' return DecimalData(float(data.number)) def decimal_to_integer_converter(data: DecimalData) -> IntegerData: ''' Convert from DecimalDat to IntegerData Parameters ---------- data : DecimalData Returns ------- value : IntegerData ''' return IntegerData(int(data.number)) def main(app): registry = nodeeditor.DataModelRegistry() models = (AdditionModel, DivisionModel, ModuloModel, MultiplicationModel, NumberSourceDataModel, SubtractionModel, NumberDisplayModel) for model in models: registry.register_model(model, category='Operations', style=None) dec_converter = TypeConverter(DecimalData.data_type, IntegerData.data_type, decimal_to_integer_converter) int_converter = TypeConverter(IntegerData.data_type, DecimalData.data_type, integer_to_decimal_converter) registry.register_type_converter(DecimalData.data_type, IntegerData.data_type, dec_converter) registry.register_type_converter(IntegerData.data_type, DecimalData.data_type, int_converter) scene = nodeeditor.FlowScene(registry=registry) view = nodeeditor.FlowView(scene) view.setWindowTitle("Calculator example") view.resize(800, 600) view.show() inputs = [] node_add = scene.create_node(AdditionModel) node_sub = scene.create_node(SubtractionModel) node_mul = scene.create_node(MultiplicationModel) node_div = scene.create_node(DivisionModel) node_mod = scene.create_node(ModuloModel) for node_operation in (node_add, node_sub, node_mul, node_div, node_mod): node_a = scene.create_node(NumberSourceDataModel) node_a.model.embedded_widget().setText('1.0') inputs.append(node_a) node_b = scene.create_node(NumberSourceDataModel) node_b.model.embedded_widget().setText('2.0') inputs.append(node_b) scene.create_connection(node_a[PortType.output][0], node_operation[PortType.input][0], ) scene.create_connection(node_b[PortType.output][0], node_operation[PortType.input][1], ) node_display = scene.create_node(NumberDisplayModel) scene.create_connection(node_operation[PortType.output][0], node_display[PortType.input][0], ) try: scene.auto_arrange(nodes=inputs, layout='bipartite') except ImportError: ... return scene, view, [node_a, node_b] if __name__ == '__main__': logging.basicConfig(level='DEBUG') app = QApplication([]) scene, view, nodes = main(app) view.show() app.exec_() qtpynodeeditor-0.2.0/qtpynodeeditor/examples/connection_colors.py000066400000000000000000000041701372375653600255700ustar00rootroot00000000000000import logging from qtpy import QtWidgets import qtpynodeeditor from qtpynodeeditor import NodeData, NodeDataModel, NodeDataType, PortType class MyNodeData(NodeData): data_type = NodeDataType(id='MyNodeData', name='My Node Data') class SimpleNodeData(NodeData): data_type = NodeDataType(id='SimpleData', name='Simple Data') class NaiveDataModel(NodeDataModel): name = 'NaiveDataModel' caption = 'Caption' caption_visible = True num_ports = {PortType.input: 2, PortType.output: 2, } data_type = { PortType.input: { 0: MyNodeData.data_type, 1: SimpleNodeData.data_type }, PortType.output: { 0: MyNodeData.data_type, 1: SimpleNodeData.data_type }, } def out_data(self, port_index): if port_index == 0: return MyNodeData() elif port_index == 1: return SimpleNodeData() def set_in_data(self, node_data, port): ... def embedded_widget(self): ... def main(app): registry = qtpynodeeditor.DataModelRegistry() registry.register_model(NaiveDataModel, category='My Category') scene = qtpynodeeditor.FlowScene(registry=registry) connection_style = scene.style_collection.connection # Configure the style collection to use colors based on data types: connection_style.use_data_defined_colors = True view = qtpynodeeditor.FlowView(scene) view.setWindowTitle("Connection (data-defined) color example") view.resize(800, 600) node_a = scene.create_node(NaiveDataModel) node_b = scene.create_node(NaiveDataModel) scene.create_connection(node_a[PortType.output][0], node_b[PortType.input][0], ) scene.create_connection(node_a[PortType.output][1], node_b[PortType.input][1], ) return scene, view, [node_a, node_b] if __name__ == '__main__': logging.basicConfig(level='DEBUG') app = QtWidgets.QApplication([]) scene, view, nodes = main(app) view.show() app.exec_() qtpynodeeditor-0.2.0/qtpynodeeditor/examples/image.py000066400000000000000000000111171372375653600231310ustar00rootroot00000000000000import logging from qtpy import QtCore, QtGui, QtWidgets from qtpy.QtCore import Qt import qtpynodeeditor from qtpynodeeditor import NodeData, NodeDataModel, NodeDataType, PortType class PixmapData(NodeData): data_type = NodeDataType(id='Pixmap', name='PixmapData') def __init__(self, pixmap): self.pixmap = pixmap class ImageLoaderModel(NodeDataModel): caption = 'Image Source' num_ports = {PortType.input: 0, PortType.output: 1, } data_type = PixmapData def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) self._pixmap = None self._label = QtWidgets.QLabel('Click to load image') self._label.setAlignment(Qt.AlignVCenter | Qt.AlignCenter) font = self._label.font() font.setBold(True) font.setItalic(True) self._label.setFont(font) self._label.setFixedSize(200, 200) self._label.installEventFilter(self) def eventFilter(self, obj, event): def set_pixmap(): w, h = self._label.width(), self._label.height() self._label.setPixmap(self._pixmap.scaled(w, h, Qt.KeepAspectRatio)) if obj is not self._label: return False if event.type() == QtCore.QEvent.MouseButtonPress: file_name, _ = QtWidgets.QFileDialog.getOpenFileName( None, "Open Image", QtCore.QDir.homePath(), "Image files (*.png *.jpg *.bmp)") try: self._pixmap = QtGui.QPixmap(file_name) except Exception as ex: print(f'Failed to load image {file_name}: {ex}') return False set_pixmap() self.data_updated.emit(0) return True elif event.type() == QtCore.QEvent.Resize: if self._pixmap is not None: set_pixmap() return False def resizable(self): return True def out_data(self, port): return PixmapData(self._pixmap) def embedded_widget(self): return self._label class ImageShowModel(NodeDataModel): caption = 'Image Display' num_ports = {PortType.input: 1, PortType.output: 1, } data_type = PixmapData def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) self._node_data = None self._label = QtWidgets.QLabel('Image will appear here') self._label.setAlignment(Qt.AlignVCenter | Qt.AlignCenter) font = self._label.font() font.setBold(True) font.setItalic(True) self._label.setFont(font) self._label.setFixedSize(200, 200) self._label.installEventFilter(self) def resizable(self): return True def eventFilter(self, obj, event): if obj is self._label and event.type() == QtCore.QEvent.Resize: if (self._node_data and self._node_data.data_type == PixmapData.data_type and self._node_data.pixmap): w, h = self._label.width(), self._label.height() pixmap = self._node_data.pixmap self._label.setPixmap(pixmap.scaled(w, h, Qt.KeepAspectRatio)) return False def set_in_data(self, node_data, port): self._node_data = node_data if (self._node_data and self._node_data.data_type == PixmapData.data_type and self._node_data.pixmap): w, h = self._label.width(), self._label.height() pixmap = node_data.pixmap.scaled(w, h, Qt.KeepAspectRatio) else: pixmap = QtGui.QPixmap() self._label.setPixmap(pixmap) self.data_updated.emit(0) def out_data(self, port): return self._node_data def embedded_widget(self): return self._label def main(app): registry = qtpynodeeditor.DataModelRegistry() registry.register_model(ImageShowModel, category='My Category') registry.register_model(ImageLoaderModel, category='My Category') scene = qtpynodeeditor.FlowScene(registry=registry) view = qtpynodeeditor.FlowView(scene) view.setWindowTitle("Image example") view.resize(800, 600) node_loader = scene.create_node(ImageLoaderModel) node_show = scene.create_node(ImageShowModel) scene.create_connection( node_loader[PortType.output][0], node_show[PortType.input][0], ) return scene, view, [node_loader, node_show] if __name__ == '__main__': logging.basicConfig(level='DEBUG') app = QtWidgets.QApplication([]) scene, view, nodes = main(app) view.show() app.exec_() qtpynodeeditor-0.2.0/qtpynodeeditor/examples/style.py000066400000000000000000000045661372375653600232210ustar00rootroot00000000000000import logging from qtpy import QtWidgets import qtpynodeeditor from qtpynodeeditor import (NodeData, NodeDataModel, NodeDataType, PortType, StyleCollection) style_json = ''' { "FlowViewStyle": { "BackgroundColor": [255, 255, 240], "FineGridColor": [245, 245, 230], "CoarseGridColor": [235, 235, 220] }, "NodeStyle": { "NormalBoundaryColor": "darkgray", "SelectedBoundaryColor": "deepskyblue", "GradientColor0": "mintcream", "GradientColor1": "mintcream", "GradientColor2": "mintcream", "GradientColor3": "mintcream", "ShadowColor": [200, 200, 200], "FontColor": [10, 10, 10], "FontColorFaded": [100, 100, 100], "ConnectionPointColor": "white", "PenWidth": 2.0, "HoveredPenWidth": 2.5, "ConnectionPointDiameter": 10.0, "Opacity": 1.0 }, "ConnectionStyle": { "ConstructionColor": "gray", "NormalColor": "black", "SelectedColor": "gray", "SelectedHaloColor": "deepskyblue", "HoveredColor": "deepskyblue", "LineWidth": 3.0, "ConstructionLineWidth": 2.0, "PointDiameter": 10.0, "UseDataDefinedColors": false } } ''' class MyNodeData(NodeData): data_type = NodeDataType(id='MyNodeData', name='My Node Data') class MyDataModel(NodeDataModel): name = 'MyDataModel' caption = 'Caption' caption_visible = True num_ports = {PortType.input: 3, PortType.output: 3, } data_type = MyNodeData.data_type def out_data(self, port): return MyNodeData() def set_in_data(self, node_data, port): ... def embedded_widget(self): return None def main(app): style = StyleCollection.from_json(style_json) registry = qtpynodeeditor.DataModelRegistry() registry.register_model(MyDataModel, category='My Category', style=style) scene = qtpynodeeditor.FlowScene(style=style, registry=registry) view = qtpynodeeditor.FlowView(scene) view.setWindowTitle("Style example") view.resize(800, 600) node = scene.create_node(MyDataModel) return scene, view, [node] if __name__ == '__main__': logging.basicConfig(level='DEBUG') app = QtWidgets.QApplication([]) scene, view, nodes = main(app) view.show() app.exec_() qtpynodeeditor-0.2.0/qtpynodeeditor/exceptions.py000066400000000000000000000015631372375653600224160ustar00rootroot00000000000000class NodeConnectionFailure(Exception): ... class ConnectionRequiresPortFailure(NodeConnectionFailure): 'A port is required' ... class ConnectionSelfFailure(NodeConnectionFailure): 'Cannot connect a node to itself' ... class ConnectionPointFailure(NodeConnectionFailure): 'Connection point is not on top of the node port' ... class ConnectionPortNotEmptyFailure(NodeConnectionFailure): 'Port should be empty' ... class ConnectionCycleFailure(NodeConnectionFailure): 'Connection would introduce a cycle in the graph' ... class ConnectionDataTypeFailure(NodeConnectionFailure): 'Ports do not have compatible data types' ... class PortsOfSameTypeError(NodeConnectionFailure): ... class PortsAlreadyConnectedError(NodeConnectionFailure): ... class MultipleInputConnectionError(NodeConnectionFailure): ... qtpynodeeditor-0.2.0/qtpynodeeditor/flow_scene.py000066400000000000000000000472161372375653600223660ustar00rootroot00000000000000import contextlib import json import os from qtpy.QtCore import QDir, QPoint, QPointF, Qt, Signal from qtpy.QtWidgets import QFileDialog, QGraphicsScene from . import exceptions from . import style as style_module from .connection import Connection from .connection_graphics_object import ConnectionGraphicsObject from .data_model_registry import DataModelRegistry from .exceptions import ConnectionDataTypeFailure from .node import Node from .node_data import NodeDataModel, NodeDataType from .node_graphics_object import NodeGraphicsObject from .port import Port, PortType from .type_converter import TypeConverter def locate_node_at(scene_point, scene, view_transform): items = scene.items(scene_point, Qt.IntersectsItemShape, Qt.DescendingOrder, view_transform) filtered_items = [item for item in items if isinstance(item, NodeGraphicsObject)] return filtered_items[0].node if filtered_items else None class FlowSceneModel: ''' A model representing a flow scene Emits the following signals upon connection/node creation/deletion:: connection_created : Signal(Connection) connection_deleted : Signal(Connection) node_created : Signal(Node) node_deleted : Signal(Node) ''' connection_created = Signal(Connection) connection_deleted = Signal(Connection) node_created = Signal(Node) node_deleted = Signal(Node) def __init__(self, registry=None, **kwargs): super().__init__(**kwargs) self._connections = [] self._nodes = {} if registry is None: registry = DataModelRegistry() self._registry = registry # this connection should come first self.connection_created.connect(self._setup_connection_signals) self.connection_created.connect(self._send_connection_created_to_nodes) self.connection_deleted.connect(self._send_connection_deleted_to_nodes) @property def registry(self) -> DataModelRegistry: """ Registry Returns ------- value : DataModelRegistry """ return self._registry @registry.setter def registry(self, registry: DataModelRegistry): self._registry = registry @property def nodes(self) -> dict: """ All nodes in the scene Returns ------- value : dict Key: uuid Value: Node """ return dict(self._nodes) @property def connections(self) -> list: """ All connections in the scene Returns ------- conn : list of Connection """ return list(self._connections) def clear_scene(self): # Manual node cleanup. Simply clearing the holding datastructures # doesn't work, the code crashes when there are both nodes and # connections in the scene. (The data propagation internal logic tries # to propagate data through already freed connections.) for conn in list(self._connections): self.delete_connection(conn) for node in list(self._nodes.values()): self.remove_node(node) def save(self, file_name=None): if file_name is None: file_name, _ = QFileDialog.getSaveFileName( None, "Save Flow Scene", QDir.homePath(), "Flow Scene Files (*.flow)") if file_name: file_name = str(file_name) if not file_name.endswith(".flow"): file_name += ".flow" with open(file_name, 'wt') as f: json.dump(self.__getstate__(), f) def load(self, file_name=None): if file_name is None: file_name, _ = QFileDialog.getOpenFileName( None, "Open Flow Scene", QDir.homePath(), "Flow Scene Files (*.flow)") if not os.path.exists(file_name): return with open(file_name, 'rt') as f: doc = json.load(f) self.__setstate__(doc) def __getstate__(self) -> dict: """ Save scene state to a dictionary Returns ------- value : dict """ scene_json = {} nodes_json_array = [] connection_json_array = [] for node in self._nodes.values(): nodes_json_array.append(node.__getstate__()) scene_json["nodes"] = nodes_json_array for connection in self._connections: connection_json = connection.__getstate__() if connection_json: connection_json_array.append(connection_json) scene_json["connections"] = connection_json_array return scene_json def __setstate__(self, doc: dict): """ Load scene state from a dictionary Parameters ---------- doc : dict Dictionary of settings """ self.clear_scene() for node in doc["nodes"]: self.restore_node(node) for connection in doc["connections"]: self.restore_connection(connection) def _setup_connection_signals(self, conn: Connection): """ Setup connection signals Parameters ---------- conn : Connection """ conn.connection_made_incomplete.connect( self.connection_deleted.emit, Qt.UniqueConnection) def _send_connection_created_to_nodes(self, conn: Connection): """ Send connection created to nodes Parameters ---------- conn : Connection """ input_node, output_node = conn.nodes assert input_node is not None assert output_node is not None output_node.model.output_connection_created(conn) input_node.model.input_connection_created(conn) def _send_connection_deleted_to_nodes(self, conn: Connection): """ Send connection deleted to nodes Parameters ---------- conn : Connection """ input_node, output_node = conn.nodes assert input_node is not None assert output_node is not None output_node.model.output_connection_deleted(conn) input_node.model.input_connection_deleted(conn) def iterate_over_nodes(self): """ Generator: Iterate over nodes """ for node in self._nodes.values(): yield node def iterate_over_node_data(self): """ Generator: Iterate over node data """ for node in self._nodes.values(): yield node.model def iterate_over_node_data_dependent_order(self): """ Generator: Iterate over node data dependent order """ visited_nodes = [] # A leaf node is a node with no input ports, or all possible input ports empty def is_node_leaf(node, model): for port in node[PortType.input].values(): if not port.connections: return False return True # Iterate over "leaf" nodes for node in self._nodes.values(): model = node.model if is_node_leaf(node, model): yield model visited_nodes.append(node) def are_node_inputs_visited_before(node, model): for port in node[PortType.input].values(): for conn in port.connections: other = conn.get_node(PortType.output) if visited_nodes and other == visited_nodes[-1]: return False return True # Iterate over dependent nodes while len(self._nodes) != len(visited_nodes): for node in self._nodes.values(): if node in visited_nodes and node is not visited_nodes[-1]: continue model = node.model if are_node_inputs_visited_before(node, model): yield model visited_nodes.append(node) def to_digraph(self): ''' Create a networkx digraph Returns ------- digraph : networkx.DiGraph The generated DiGraph Raises ------ ImportError If networkx is unavailable ''' import networkx graph = networkx.DiGraph() for node in self._nodes.values(): graph.add_node(node) for node in self._nodes.values(): graph.add_edges_from(conn.nodes for conn in node.state.all_connections) return graph def remove_node(self, node: Node): """ Remove node Parameters ---------- node : Node """ self.node_deleted.emit(node) for conn in list(node.state.all_connections): self.delete_connection(conn) node._cleanup() del self._nodes[node.id] def _restore_node(self, node_json: dict) -> Node: """ Restore a node from a state dictionary Parameters ---------- node_json : dict Returns ------- value : Node """ with self._new_node_context(node_json["model"]["name"]) as node: ... return node @contextlib.contextmanager def _new_node_context(self, data_model_name, *, emit_placed=False): 'Context manager: creates Node/yields it, handling necessary Signals' data_model = self._registry.create(data_model_name) node = Node(data_model) yield node self._nodes[node.id] = node if emit_placed: self.node_placed.emit(node) self.node_created.emit(node) def restore_node(self, node_json: dict) -> Node: """ Restore a node from a state dictionary Parameters ---------- node_json : dict Returns ------- value : Node """ name = node_json["model"]["name"] with self._new_node_context(name, emit_placed=True) as node: node.__setstate__(node_json) return node def delete_connection(self, connection: Connection): """ Delete connection Parameters ---------- connection : Connection """ try: self._connections.remove(connection) except ValueError: ... else: connection.remove_from_nodes() connection._cleanup() class FlowScene(FlowSceneModel, QGraphicsScene): connection_hover_left = Signal(Connection) connection_hovered = Signal(Connection, QPoint) # Node has been added to the scene. # Connect to self signal if need a correct position of node. node_placed = Signal(Node) # node_context_menu(node, scene_position, screen_position) node_context_menu = Signal(Node, QPointF, QPoint) node_double_clicked = Signal(Node) node_hover_left = Signal(Node) node_hovered = Signal(Node, QPoint) node_moved = Signal(Node, QPointF) def __init__(self, registry=None, style=None, parent=None, allow_node_creation=True, allow_node_deletion=True): ''' Create a new flow scene Parameters ---------- registry : DataModelRegistry, optional style : StyleCollection, optional parent : QObject, optional ''' super().__init__(parent=parent) self._registry = registry or self._registry if style is None: style = style_module.default_style self._style = style self.allow_node_deletion = allow_node_creation self.allow_node_creation = allow_node_deletion self.setItemIndexMethod(QGraphicsScene.NoIndex) def _cleanup(self): self.clear_scene() @property def allow_node_creation(self): return self._allow_node_creation @allow_node_creation.setter def allow_node_creation(self, allow): self._allow_node_creation = bool(allow) @property def allow_node_deletion(self): return self._allow_node_deletion @allow_node_deletion.setter def allow_node_deletion(self, allow): self._allow_node_deletion = bool(allow) @property def style_collection(self) -> style_module.StyleCollection: 'The style collection for the scene' return self._style def locate_node_at(self, point, transform): return locate_node_at(point, self, transform) def create_connection(self, port_a: Port, port_b: Port = None, *, converter: TypeConverter = None, check_cycles=True) -> Connection: """ Create a connection Parameters ---------- port_a : Port The first port, either input or output port_b : Port, optional The second port, opposite of the type of port_a converter : TypeConverter, optional The type converter to use for data propagation check_cycles : bool, optional Ensures that creating the connection would not introduce a cycle Returns ------- value : Connection Raises ------ NodeConnectionFailure If it is not possible to create the connection ConnectionDataTypeFailure If port data types are not compatible """ if port_a is not None and port_b is not None: in_port = port_a if port_a.port_type == PortType.input else port_b out_port = port_b if port_a.port_type == PortType.input else port_a if in_port.data_type.id != out_port.data_type.id: if not converter: # If not specified, try to get it from the registry converter = self.registry.get_type_converter(out_port.data_type, in_port.data_type) if (not converter or (converter.type_in != out_port.data_type or converter.type_out != in_port.data_type)): raise ConnectionDataTypeFailure( f'{in_port.data_type} and {out_port.data_type} are not compatible' ) connection = Connection(port_a=port_a, port_b=port_b, style=self._style, converter=converter) if port_a is not None: port_a.add_connection(connection) if port_b is not None: port_b.add_connection(connection) if port_a and port_b and check_cycles: # In the case of a fully-specified connection, ensure adding the # connection would not create a cycle in the graph. For # partially-specified connections (i.e., one port only), the # validation happens in the NodeConnectionInteraction node_a, node_b = port_a.node, port_b.node if node_a.has_connection_by_port_type(node_b, port_b.port_type): raise exceptions.ConnectionCycleFailure( f'Connecting {node_a} and {node_b} would introduce a ' f'cycle in the graph' ) cgo = ConnectionGraphicsObject(self, connection) # after self function connection points are set to node port connection.graphics_object = cgo self._connections.append(connection) if not port_a or not port_b: # This connection isn't truly created yet. It's only partially # created. Thus, don't send the connection_created(...) signal. connection.connection_completed.connect(self.connection_created.emit) else: in_port, out_port = connection.ports out_port.node.on_data_updated(out_port) self.connection_created.emit(connection) return connection def create_connection_by_index( self, node_in: Node, port_index_in: int, node_out: Node, port_index_out: int, converter: TypeConverter) -> Connection: """ Create connection Parameters ---------- node_in : Node port_index_in : int node_out : Node port_index_out : int converter : TypeConverter Returns ------- value : Connection """ port_in = node_in[PortType.input][port_index_in] port_out = node_out[PortType.output][port_index_out] return self.create_connection(port_out, port_in, converter=converter) def restore_connection(self, connection_json: dict) -> Connection: """ Restore connection Parameters ---------- connection_json : dict Returns ------- value : Connection """ node_in_id = connection_json["in_id"] node_out_id = connection_json["out_id"] port_index_in = connection_json["in_index"] port_index_out = connection_json["out_index"] node_in = self._nodes[node_in_id] node_out = self._nodes[node_out_id] def get_converter(): converter = connection_json.get("converter", None) if converter is None: return None in_type = NodeDataType( id=converter["in"]["id"], name=converter["in"]["name"], ) out_type = NodeDataType( id=converter["out"]["id"], name=converter["out"]["name"], ) return self._registry.get_type_converter(out_type, in_type) connection = self.create_connection_by_index( node_in, port_index_in, node_out, port_index_out, converter=get_converter()) # Note: the connection_created(...) signal has already been sent by # create_connection(...) return connection def create_node(self, data_model: NodeDataModel) -> Node: """ Create a node in the scene Parameters ---------- data_model : NodeDataModel Returns ------- value : Node """ with self._new_node_context(data_model.name) as node: ngo = NodeGraphicsObject(self, node) node.graphics_object = ngo return node def restore_node(self, node_json: dict) -> Node: """ Restore a node from a state dictinoary Parameters ---------- node_json : dict Returns ------- value : Node """ # NOTE: Overrides FlowSceneModel.restore_node with self._new_node_context(node_json["model"]["name"]) as node: node.graphics_object = NodeGraphicsObject(self, node) node.__setstate__(node_json) return node def auto_arrange(self, layout='bipartite', scale=700, align='horizontal', **kwargs): ''' Automatically arrange nodes with networkx, if available Raises ------ ImportError If networkx is unavailable ''' import networkx dig = self.to_digraph() layouts = { name: getattr(networkx.layout, '{}_layout'.format(name)) for name in ('bipartite', 'circular', 'kamada_kawai', 'random', 'shell', 'spring', 'spectral') } try: layout_func = layouts[layout] except KeyError: raise ValueError('Unknown layout type {}'.format(layout)) from None layout = layout_func(dig, **kwargs) for node, pos in layout.items(): pos_x, pos_y = pos node.position = (pos_x * scale, pos_y * scale) def selected_nodes(self) -> list: """ Selected nodes Returns ------- value : list of Node """ return [item.node for item in self.selectedItems() if isinstance(item, NodeGraphicsObject)] qtpynodeeditor-0.2.0/qtpynodeeditor/flow_view.py000066400000000000000000000254071372375653600222410ustar00rootroot00000000000000import logging import math from qtpy.QtCore import QLineF, QPoint, QRectF, Qt from qtpy.QtGui import (QContextMenuEvent, QKeyEvent, QMouseEvent, QPainter, QPen, QShowEvent, QWheelEvent, QKeySequence) from qtpy.QtWidgets import (QAction, QGraphicsView, QLineEdit, QMenu, QTreeWidget, QTreeWidgetItem, QWidgetAction) from .connection_graphics_object import ConnectionGraphicsObject from .flow_scene import FlowScene from .node_graphics_object import NodeGraphicsObject logger = logging.getLogger(__name__) class FlowView(QGraphicsView): def __init__(self, scene, parent=None): super().__init__(parent=parent) self._clear_selection_action = None self._delete_selection_action = None self._scene = None self._click_pos = None self.setDragMode(QGraphicsView.ScrollHandDrag) self.setRenderHint(QPainter.Antialiasing) # setViewportUpdateMode(QGraphicsView.FullViewportUpdate) # setViewportUpdateMode(QGraphicsView.MinimalViewportUpdate) self.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff) self.setVerticalScrollBarPolicy(Qt.ScrollBarAlwaysOff) self.setTransformationAnchor(QGraphicsView.AnchorUnderMouse) self.setCacheMode(QGraphicsView.CacheBackground) # setViewport(new QGLWidget(QGLFormat(QGL.SampleBuffers))) if scene is not None: self.setScene(scene) self._style = self._scene.style_collection self.setBackgroundBrush(self._style.flow_view.background_color) def clear_selection_action(self) -> QAction: """ Clear selection action Returns ------- value : QAction """ return self._clear_selection_action def delete_selection_action(self) -> QAction: """ Delete selection action Returns ------- value : QAction """ return self._delete_selection_action def setScene(self, scene: FlowScene): """ setScene Parameters ---------- scene : FlowScene """ self._scene = scene super().setScene(self._scene) # setup actions del self._clear_selection_action self._clear_selection_action = QAction("Clear Selection", self) self._clear_selection_action.setShortcut(QKeySequence.Cancel) self._clear_selection_action.triggered.connect(self._scene.clearSelection) self.addAction(self._clear_selection_action) del self._delete_selection_action self._delete_selection_action = QAction("Delete Selection", self) self._delete_selection_action.setShortcut(QKeySequence.Backspace) self._delete_selection_action.setShortcut(QKeySequence.Delete) self._delete_selection_action.triggered.connect(self.delete_selected) self.addAction(self._delete_selection_action) def scale_up(self): step = 1.2 factor = step ** 1.0 t = self.transform() if t.m11() <= 2.0: self.scale(factor, factor) def scale_down(self): step = 1.2 factor = step ** -1.0 self.scale(factor, factor) def delete_selected(self): # Delete the selected connections first, ensuring that they won't be # automatically deleted when selected nodes are deleted (deleting a node # deletes some connections as well) for item in self._scene.selectedItems(): if isinstance(item, ConnectionGraphicsObject): self._scene.delete_connection(item.connection) if not self._scene.allow_node_deletion: return # Delete the nodes; self will delete many of the connections. # Selected connections were already deleted prior to self loop, otherwise # qgraphicsitem_cast(item) could be a use-after-free # when a selected connection is deleted by deleting the node. for item in self._scene.selectedItems(): if isinstance(item, NodeGraphicsObject): self._scene.remove_node(item.node) def generate_context_menu(self, pos: QPoint): """ Generate a context menu for contextMenuEvent Parameters ---------- pos : QPoint The point where the context menu was requested """ model_menu = QMenu() skip_text = "skip me" # Add filterbox to the context menu txt_box = QLineEdit(model_menu) txt_box.setPlaceholderText("Filter") txt_box.setClearButtonEnabled(True) txt_box_action = QWidgetAction(model_menu) txt_box_action.setDefaultWidget(txt_box) model_menu.addAction(txt_box_action) # Add result treeview to the context menu tree_view = QTreeWidget(model_menu) tree_view.header().close() tree_view_action = QWidgetAction(model_menu) tree_view_action.setDefaultWidget(tree_view) model_menu.addAction(tree_view_action) top_level_items = {} for cat in self._scene.registry.categories(): item = QTreeWidgetItem(tree_view) item.setText(0, cat) item.setData(0, Qt.UserRole, skip_text) top_level_items[cat] = item registry = self._scene.registry for model, category in registry.registered_models_category_association().items(): self.parent = top_level_items[category] item = QTreeWidgetItem(self.parent) item.setText(0, model) item.setData(0, Qt.UserRole, model) tree_view.expandAll() def click_handler(item): model_name = item.data(0, Qt.UserRole) if model_name == skip_text: return try: model, _ = self._scene.registry.get_model_by_name(model_name) except ValueError: logger.error("Model not found: %s", model_name) else: node = self._scene.create_node(model) pos_view = self.mapToScene(pos) node.graphics_object.setPos(pos_view) self._scene.node_placed.emit(node) model_menu.close() tree_view.itemClicked.connect(click_handler) # Setup filtering def filter_handler(text): for name, top_lvl_item in top_level_items.items(): for i in range(top_lvl_item.childCount()): child = top_lvl_item.child(i) model_name = child.data(0, Qt.UserRole) child.setHidden(text not in model_name) txt_box.textChanged.connect(filter_handler) # make sure the text box gets focus so the user doesn't have to click on it txt_box.setFocus() return model_menu def contextMenuEvent(self, event: QContextMenuEvent): """ contextMenuEvent Parameters ---------- event : QContextMenuEvent """ if self.itemAt(event.pos()): super().contextMenuEvent(event) return elif not self._scene.allow_node_creation: return menu = self.generate_context_menu(event.pos()) menu.exec_(event.globalPos()) def wheelEvent(self, event: QWheelEvent): """ wheelEvent Parameters ---------- event : QWheelEvent """ delta = event.angleDelta() if delta.y() == 0: event.ignore() return d = delta.y() / abs(delta.y()) if d > 0.0: self.scale_up() else: self.scale_down() def keyPressEvent(self, event: QKeyEvent): """ keyPressEvent Parameters ---------- event : QKeyEvent """ if event.key() == Qt.Key_Shift: self.setDragMode(QGraphicsView.RubberBandDrag) super().keyPressEvent(event) def keyReleaseEvent(self, event: QKeyEvent): """ keyReleaseEvent Parameters ---------- event : QKeyEvent """ if event.key() == Qt.Key_Shift: self.setDragMode(QGraphicsView.ScrollHandDrag) super().keyReleaseEvent(event) def mousePressEvent(self, event: QMouseEvent): """ mousePressEvent Parameters ---------- event : QMouseEvent """ super().mousePressEvent(event) if event.button() == Qt.LeftButton: self._click_pos = self.mapToScene(event.pos()) def mouseMoveEvent(self, event: QMouseEvent): """ mouseMoveEvent Parameters ---------- event : QMouseEvent """ super().mouseMoveEvent(event) if self._scene.mouseGrabberItem() is None and event.buttons() == Qt.LeftButton: # Make sure shift is not being pressed if not (event.modifiers() & Qt.ShiftModifier): difference = self._click_pos - self.mapToScene(event.pos()) self.setSceneRect(self.sceneRect().translated(difference.x(), difference.y())) def drawBackground(self, painter: QPainter, r: QRectF): """ drawBackground Parameters ---------- painter : QPainter r : QRectF """ super().drawBackground(painter, r) def draw_grid(grid_step): window_rect = self.rect() tl = self.mapToScene(window_rect.topLeft()) br = self.mapToScene(window_rect.bottomRight()) left = math.floor(tl.x() / grid_step - 0.5) right = math.floor(br.x() / grid_step + 1.0) bottom = math.floor(tl.y() / grid_step - 0.5) top = math.floor(br.y() / grid_step + 1.0) # vertical lines lines = [ QLineF(xi * grid_step, bottom * grid_step, xi * grid_step, top * grid_step) for xi in range(int(left), int(right) + 1) ] # horizontal lines lines.extend( [QLineF(left * grid_step, yi * grid_step, right * grid_step, yi * grid_step) for yi in range(int(bottom), int(top) + 1) ] ) painter.drawLines(lines) style = self._style.flow_view # brush = self.backgroundBrush() pfine = QPen(style.fine_grid_color, 1.0) painter.setPen(pfine) draw_grid(15) p = QPen(style.coarse_grid_color, 1.0) painter.setPen(p) draw_grid(150) def showEvent(self, event: QShowEvent): """ showEvent Parameters ---------- event : QShowEvent """ self._scene.setSceneRect(QRectF(self.rect())) super().showEvent(event) @property def scene(self) -> FlowScene: """ Scene Returns ------- value : FlowScene """ return self._scene qtpynodeeditor-0.2.0/qtpynodeeditor/node.py000066400000000000000000000226741372375653600211700ustar00rootroot00000000000000import collections import typing import uuid from qtpy.QtCore import QObject, QPointF, QSizeF from .base import NodeBase, Serializable from .enums import ReactToConnectionState from .node_data import NodeData, NodeDataModel, NodeDataType from .node_geometry import NodeGeometry from .node_graphics_object import NodeGraphicsObject from .node_state import NodeState from .port import Port, PortType from .style import NodeStyle class Node(QObject, Serializable, NodeBase): def __init__(self, data_model: NodeDataModel): ''' A single Node in the scene Parameters ---------- data_model : NodeDataModel ''' super().__init__() self._model = data_model self._uid = str(uuid.uuid4()) self._style = data_model.node_style self._state = NodeState(self) self._geometry = NodeGeometry(self) self._graphics_obj = None self._geometry.recalculate_size() # propagate data: model => node self._model.data_updated.connect(self._on_port_index_data_updated) self._model.embedded_widget_size_updated.connect(self.on_node_size_updated) def __hash__(self): return id(self._uid) def __eq__(self, node): try: return node.id == self.id and self.model is node.model except AttributeError: return False def has_any_connection(self, node: 'Node') -> bool: """ Is this node connected to `node` through any port? Parameters ---------- node : Node The node to check connectivity Returns ------- connected : bool """ return any(self.has_connection_by_port_type(node, port_type) for port_type in PortType) def has_connection_by_port_type(self, target: 'Node', port_type: PortType) -> bool: """ Is this node connected to `target` through an input/output port? Parameters ---------- target : Node The target node to check connectivity port_type : PortType The port type (``PortType.input``, ``PortType.output``) to check Returns ------- connected : bool """ return any( path[-1] == target for path in self.walk_paths_by_port_type(port_type) ) def walk_paths_by_port_type( self, port_type: PortType) -> typing.Iterable['Node']: """ Yields paths to connected nodes by port type Yields ------ node_path : tuple The path to the node """ seen = set([None]) pending = collections.deque([([], self)]) if port_type == PortType.output: def get_connection_nodes(state): for con in state.output_connections: yield con.input_node elif port_type == PortType.input: def get_connection_nodes(state): for con in state.input_connections: yield con.output_node else: raise ValueError(f'Unexpected port_type {port_type}') while pending: node_path, node = pending.popleft() seen.add(node) if node is not self: yield tuple(node_path) + (node, ) node_path = list(node_path) + [node] for node in get_connection_nodes(node.state): if node not in seen: pending.append((node_path, node)) def __getitem__(self, key): return self._state[key] def _cleanup(self): if self._graphics_obj is not None: self._graphics_obj._cleanup() self._graphics_obj = None self._geometry = None def __getstate__(self) -> dict: """ Save Returns ------- value : dict """ return { "id": self._uid, "model": self._model.__getstate__(), "position": {"x": self._graphics_obj.pos().x(), "y": self._graphics_obj.pos().y()} } def __setstate__(self, state: dict): """ Restore Parameters ---------- state : dict """ self._uid = state["id"] if self._graphics_obj: pos = state["position"] self.position = (pos["x"], pos["y"]) self._model.__setstate__(state["model"]) @property def id(self) -> str: """ Node unique identifier (uuid) Returns ------- value : str """ return self._uid def react_to_possible_connection(self, reacting_port_type: PortType, reacting_data_type: NodeDataType, scene_point: QPointF ): """ React to possible connection Parameters ---------- port_type : PortType node_data_type : NodeDataType scene_point : QPointF """ transform = self._graphics_obj.sceneTransform() inverted, invertible = transform.inverted() if invertible: pos = inverted.map(scene_point) self._geometry.dragging_position = pos self._graphics_obj.update() self._state.set_reaction(ReactToConnectionState.reacting, reacting_port_type, reacting_data_type) def reset_reaction_to_connection(self): self._state.set_reaction(ReactToConnectionState.not_reacting) self._graphics_obj.update() @property def graphics_object(self) -> NodeGraphicsObject: """ Node graphics object Returns ------- value : NodeGraphicsObject """ return self._graphics_obj @graphics_object.setter def graphics_object(self, graphics: NodeGraphicsObject): """ Set graphics object Parameters ---------- graphics : NodeGraphicsObject """ self._graphics_obj = graphics self._geometry.recalculate_size() @property def geometry(self) -> NodeGeometry: """ Node geometry Returns ------- value : NodeGeometry """ return self._geometry @property def model(self) -> NodeDataModel: """ Node data model Returns ------- value : NodeDataModel """ return self._model def propagate_data(self, node_data: NodeData, input_port: Port): """ Propagates incoming data to the underlying model. Parameters ---------- node_data : NodeData input_port : int """ if input_port.node is not self: raise ValueError('Port does not belong to this Node') elif input_port.port_type != PortType.input: raise ValueError('Port is not an input port') self._model.set_in_data(node_data, input_port) # Recalculate the nodes visuals. A data change can result in the node # taking more space than before, so self forces a recalculate+repaint # on the affected node self._graphics_obj.set_geometry_changed() self._geometry.recalculate_size() self._graphics_obj.update() self._graphics_obj.move_connections() def _on_port_index_data_updated(self, port_index: int): """ Data has been updated on this Node's output port port_index; propagate it to any connections. Parameters ---------- index : int """ port = self[PortType.output][port_index] self.on_data_updated(port) def on_data_updated(self, port: Port): """ Fetches data from model's output port and propagates it along the connection Parameters ---------- port : Port """ node_data = port.data for conn in port.connections: conn.propagate_data(node_data) def on_node_size_updated(self): """ update the graphic part if the size of the embeddedwidget changes """ widget = self.model.embedded_widget() if widget: widget.adjustSize() self.geometry.recalculate_size() for conn in self.state.all_connections: conn.graphics_object.move() @property def size(self) -> QSizeF: """ Get the node size Parameters ---------- node : Node Returns ------- value : QSizeF """ return self._geometry.size @property def position(self) -> QPointF: """ Get the node position Parameters ---------- node : Node Returns ------- value : QPointF """ return self._graphics_obj.pos() @position.setter def position(self, pos): if not isinstance(pos, QPointF): px, py = pos pos = QPointF(px, py) self._graphics_obj.setPos(pos) self._graphics_obj.move_connections() @property def style(self) -> NodeStyle: 'Node style' return self._style @property def state(self) -> NodeState: """ Node state Returns ------- value : NodeState """ return self._state def __repr__(self): return (f'<{self.__class__.__name__} model={self._model} ' f'uid={self._uid!r}>') qtpynodeeditor-0.2.0/qtpynodeeditor/node_connection_interaction.py000066400000000000000000000222071372375653600257760ustar00rootroot00000000000000import logging from qtpy.QtCore import QPointF from .base import ConnectionBase, FlowSceneBase, NodeBase from .exceptions import (ConnectionCycleFailure, ConnectionPointFailure, ConnectionPortNotEmptyFailure, ConnectionRequiresPortFailure, ConnectionSelfFailure, ConnectionDataTypeFailure, NodeConnectionFailure) from .port import PortType, opposite_port logger = logging.getLogger(__name__) class NodeConnectionInteraction: def __init__(self, node: NodeBase, connection: ConnectionBase, scene: FlowSceneBase): ''' An interactive connection interaction to complete `connection` with the given node Parameters ---------- node : Node connection : Connection scene : FlowScene ''' self._node = node self._connection = connection self._scene = scene @property def creates_cycle(self): """Would completing the connection introduce a cycle?""" required_port = self.connection_required_port return self.connection_node.has_connection_by_port_type( self._node, required_port) def can_connect(self) -> bool: """ Can connect when following conditions are met: 1) Connection 'requires' a port - i.e., is missing either a start node or an end node 2) Connection's vacant end is above the node port in the user interface 3) Node port is vacant 4) Connection does not introduce a cycle in the graph 5) Connection type equals node port type, or there is a registered type conversion that can translate between the two Parameters ---------- Returns ------- (port_index, converter) : (int, TypeConverter) where port_index is the index of the port to be connected Raises ------ NodeConnectionFailure ConnectionDataTypeFailure If port data types are not compatible """ # 1) Connection requires a port required_port = self.connection_required_port if required_port == PortType.none: raise ConnectionRequiresPortFailure('Connection requires a port') elif required_port not in (PortType.input, PortType.output): raise ValueError(f'Invalid port specified {required_port}') # 1.5) Forbid connecting the node to itself node = self.connection_node if node == self._node: raise ConnectionSelfFailure(f'Cannot connect {node} to itself') # 2) connection point is on top of the node port connection_point = self.connection_end_scene_position(required_port) port = self.node_port_under_scene_point(required_port, connection_point) if not port: raise ConnectionPointFailure( f'Connection point {connection_point} is not on node {node}') # 3) Node port is vacant if not port.can_connect: raise ConnectionPortNotEmptyFailure( f'Port {required_port} {port} cannot connect' ) # 4) Cycle check if self.creates_cycle: raise ConnectionCycleFailure( f'Connecting {self._node} and {node} would introduce a ' f'cycle in the graph' ) # 5) Connection type equals node port type, or there is a registered # type conversion that can translate between the two connection_data_type = self._connection.data_type(opposite_port(required_port)) candidate_node_data_type = port.data_type if connection_data_type.id == candidate_node_data_type.id: return port, None registry = self._scene.registry if required_port == PortType.input: converter = registry.get_type_converter(connection_data_type, candidate_node_data_type) else: converter = registry.get_type_converter(candidate_node_data_type, connection_data_type) if not converter: raise ConnectionDataTypeFailure( f'{connection_data_type} and {candidate_node_data_type} are not compatible' ) return port, converter def try_connect(self) -> bool: """ Try to connect the nodes. Steps:: 1) Check conditions from 'can_connect' 1.5) If the connection is possible but a type conversion is needed, add a converter node to the scene, and connect it properly 2) Assign node to required port in Connection 3) Assign Connection to empty port in NodeState 4) Adjust Connection geometry 5) Poke model to initiate data transfer Returns ------- value : bool """ # 1) Check conditions from 'can_connect' try: port, converter = self.can_connect() except NodeConnectionFailure as ex: logger.debug('Cannot connect node', exc_info=ex) logger.info('Cannot connect node: %s', ex) return False # 1.5) If the connection is possible but a type conversion is needed, # assign a convertor to connection if converter: self._connection.type_converter = converter # 2) Assign node to required port in Connection port.add_connection(self._connection) # 3) Assign Connection to empty port in NodeState # The port is not longer required after this function self._connection.connect_to(port) # 4) Adjust Connection geometry self._node.graphics_object.move_connections() # 5) Poke model to intiate data transfer _, out_port = self._connection.ports if out_port: out_port.node.on_data_updated(out_port) return True def disconnect(self, port_to_disconnect: PortType) -> bool: """ 1) Node and Connection should be already connected 2) If so, clear Connection entry in the NodeState 3) Propagate invalid data to IN node 4) Set Connection end to 'requiring a port' Parameters ---------- port_to_disconnect : PortType Returns ------- value : bool """ port_index = self._connection.get_port_index(port_to_disconnect) state = self._node.state # clear pointer to Connection in the NodeState state.erase_connection(port_to_disconnect, port_index, self._connection) # Propagate invalid data to IN node self._connection.propagate_empty_data() # clear Connection side self._connection.clear_node(port_to_disconnect) self._connection.required_port = port_to_disconnect self._connection.graphics_object.grabMouse() @property def connection_required_port(self) -> PortType: """ The required port type to complete the connection Returns ------- value : PortType """ return self._connection.required_port @property def connection_node(self): """The node already specified for the connection""" required_port = self.connection_required_port return self._connection.get_node(opposite_port(required_port)) def connection_end_scene_position(self, port_type: PortType) -> QPointF: """ Connection end scene position Parameters ---------- port_type : PortType Returns ------- value : QPointF """ go = self._connection.graphics_object geometry = self._connection.geometry end_point = geometry.get_end_point(port_type) return go.mapToScene(end_point) def node_port_scene_position(self, port_type: PortType, port_index: int) -> QPointF: """ Node port scene position Parameters ---------- port_type : PortType port_index : int Returns ------- value : QPointF """ port = self._node.state[port_type][port_index] return port.get_mapped_scene_position( self._node.graphics_object.sceneTransform()) def node_port_under_scene_point(self, port_type: PortType, scene_point: QPointF) -> NodeBase: """ Node port under scene point Parameters ---------- port_type : PortType p : QPointF Returns ------- value : int """ node_geom = self._node.geometry scene_transform = self._node.graphics_object.sceneTransform() return node_geom.check_hit_scene_point(port_type, scene_point, scene_transform) def node_port_is_empty(self, port_type: PortType, port_index: int) -> bool: """ Node port is empty Parameters ---------- port_type : PortType port_index : int Returns ------- value : bool """ port = self._node.state[port_type][port_index] return port.can_connect qtpynodeeditor-0.2.0/qtpynodeeditor/node_data.py000066400000000000000000000240331372375653600221500ustar00rootroot00000000000000import inspect from collections import namedtuple from qtpy.QtCore import QObject, Signal from qtpy.QtWidgets import QWidget from . import style as style_module from .base import Serializable from .enums import ConnectionPolicy, NodeValidationState, PortType from .port import Port NodeDataType = namedtuple('NodeDataType', ('id', 'name')) class NodeData: """ Class represents data transferred between nodes. The actual data is stored in subtypes """ data_type = NodeDataType(None, None) def __init_subclass__(cls, **kwargs): super().__init_subclass__(**kwargs) if cls.data_type is None: raise ValueError('Subclasses must set the `data_type` attribute') def same_type(self, other) -> bool: """ Is another NodeData instance of the same type? Parameters ---------- other : NodeData Returns ------- value : bool """ return self.data_type.id == other.data_type.id class NodeDataModel(QObject, Serializable): name = None caption = None caption_visible = True num_ports = {PortType.input: 1, PortType.output: 1, } # data_updated and data_invalidated refer to the port index that has # changed: data_updated = Signal(int) data_invalidated = Signal(int) computing_started = Signal() computing_finished = Signal() embedded_widget_size_updated = Signal() def __init__(self, style=None, parent=None): super().__init__(parent=parent) if style is None: style = style_module.default_style self._style = style def __init_subclass__(cls, verify=True, **kwargs): super().__init_subclass__(**kwargs) # For all subclasses, if no name is defined, default to the class name if cls.name is None: cls.name = cls.__name__ if cls.caption is None and cls.caption_visible: cls.caption = cls.name num_ports = cls.num_ports if isinstance(num_ports, property): # Dynamically defined - that's OK, but we can't verify it. return if verify: cls._verify() @classmethod def _verify(cls): ''' Verify the data model won't crash in strange spots Ensure valid dictionaries: - num_ports - data_type - port_caption - port_caption_visible ''' num_ports = cls.num_ports if isinstance(num_ports, property): # Dynamically defined - that's OK, but we can't verify it. return assert set(num_ports.keys()) == {'input', 'output'} # TODO while the end result is nicer, this is ugly; refactor away... def new_dict(value): return { PortType.input: {i: value for i in range(num_ports[PortType.input]) }, PortType.output: {i: value for i in range(num_ports[PortType.output]) }, } def get_default(attr, default, valid_type): current = getattr(cls, attr, None) if current is None: # Unset - use the default return default if valid_type is not None: if isinstance(current, valid_type): # Fill in the dictionary with the user-provided value return current if attr == 'data_type' and inspect.isclass(current): if issubclass(current, NodeData): return current.data_type if inspect.ismethod(current) or inspect.isfunction(current): raise ValueError('{} should not be a function; saw: {}\n' 'Did you forget a @property decorator?' ''.format(attr, current)) try: type(default)(current) except TypeError: raise ValueError('{} is of an unexpected type: {}' ''.format(attr, current)) from None # Fill in the dictionary with the given value return current def fill_defaults(attr, default, valid_type=None): if isinstance(getattr(cls, attr, None), dict): return default = get_default(attr, default, valid_type) if default is None: raise ValueError('Cannot leave {} unspecified'.format(attr)) setattr(cls, attr, new_dict(default)) fill_defaults('port_caption', '') fill_defaults('port_caption_visible', False) fill_defaults('data_type', None, valid_type=NodeDataType) reasons = [] for attr in ('data_type', 'port_caption', 'port_caption_visible'): try: dct = getattr(cls, attr) except AttributeError: reasons.append('{} is missing dictionary: {}' ''.format(cls.__name__, attr)) continue if isinstance(dct, property): continue for port_type in {'input', 'output'}: if port_type not in dct: if num_ports[port_type] == 0: dct[port_type] = {} else: reasons.append('Port type key {}[{!r}] missing' ''.format(attr, port_type)) continue for i in range(num_ports[port_type]): if i not in dct[port_type]: reasons.append('Port key {}[{!r}][{}] missing' ''.format(attr, port_type, i)) if reasons: reason_text = '\n'.join('* {}'.format(reason) for reason in reasons) raise ValueError( 'Verification of NodeDataModel class failed:\n{}' ''.format(reason_text) ) @property def style(self): 'Style collection for drawing this data model' return self._style def save(self) -> dict: """ Subclasses may implement this to save additional state for pickling/saving to JSON. Returns ------- value : dict """ return {} def restore(self, doc: dict): """ Subclasses may implement this to load additional state from pickled or saved-to-JSON data. Parameters ---------- value : dict """ return {} def __setstate__(self, doc: dict): """ Set the state of the NodeDataModel Parameters ---------- doc : dict """ self.restore(doc) return doc def __getstate__(self) -> dict: """ Get the state of the NodeDataModel for saving/pickling Returns ------- value : QJsonObject """ doc = {'name': self.name} doc.update(**self.save()) return doc @property def data_type(self): """ Data type placeholder - to be implemented by subclass. Parameters ---------- port_type : PortType port_index : int Returns ------- value : NodeDataType """ raise NotImplementedError(f'Subclass {self.__class__.__name__} must ' f'implement `data_type`') def port_out_connection_policy(self, port_index: int) -> ConnectionPolicy: """ Port out connection policy Parameters ---------- port_index : int Returns ------- value : ConnectionPolicy """ return ConnectionPolicy.many @property def node_style(self) -> style_module.NodeStyle: """ Node style Returns ------- value : NodeStyle """ return self._style.node def set_in_data(self, node_data: NodeData, port: Port): """ Triggers the algorithm; to be overridden by subclasses Parameters ---------- node_data : NodeData port : Port """ ... def out_data(self, port: int) -> NodeData: """ Out data Parameters ---------- port : int Returns ------- value : NodeData """ ... def embedded_widget(self) -> QWidget: """ Embedded widget Returns ------- value : QWidget """ ... def resizable(self) -> bool: """ Resizable Returns ------- value : bool """ return False def validation_state(self) -> NodeValidationState: """ Validation state Returns ------- value : NodeValidationState """ return NodeValidationState.valid def validation_message(self) -> str: """ Validation message Returns ------- value : str """ return "" def painter_delegate(self): """ Painter delegate Returns ------- value : NodePainterDelegate """ return None def input_connection_created(self, connection): """ Input connection created Parameters ---------- connection : Connection """ ... def input_connection_deleted(self, connection): """ Input connection deleted Parameters ---------- connection : Connection """ ... def output_connection_created(self, connection): """ Output connection created Parameters ---------- connection : Connection """ ... def output_connection_deleted(self, connection): """ Output connection deleted Parameters ---------- connection : Connection """ ... qtpynodeeditor-0.2.0/qtpynodeeditor/node_geometry.py000066400000000000000000000313561372375653600231000ustar00rootroot00000000000000import math from qtpy.QtCore import QPointF, QRect, QRectF, QSizeF from qtpy.QtGui import QFont, QFontMetrics, QTransform from qtpy.QtWidgets import QSizePolicy from .base import NodeBase from .enums import NodeValidationState, PortType from .port import Port class NodeGeometry: def __init__(self, node: NodeBase): super().__init__() self._node = node self._model = node.model self._dragging_pos = QPointF(-1000, -1000) self._entry_width = 0 self._entry_height = 20 self._font_metrics = QFontMetrics(QFont()) self._height = 150 self._hovered = False self._input_port_width = 70 self._output_port_width = 70 self._spacing = 20 self._style = node.style self._width = 100 f = QFont() f.setBold(True) self._bold_font_metrics = QFontMetrics(f) @property def height(self) -> int: """ Height Returns ------- value : int """ return self._height @height.setter def height(self, h: int): self._height = int(h) @property def width(self) -> int: """ Width Returns ------- value : int """ return self._width @width.setter def width(self, width: int): """ Set width Parameters ---------- width : int """ self._width = int(width) @property def entry_height(self) -> int: """ Entry height Returns ------- value : int """ return self._entry_height @entry_height.setter def entry_height(self, h: int): """ Set entry height Parameters ---------- h : int """ self._entry_height = int(h) @property def entry_width(self) -> int: """ Entry width Returns ------- value : int """ return self._entry_width @entry_width.setter def entry_width(self, width: int): """ Set entry width Parameters ---------- width : int """ self._entry_width = int(width) @property def spacing(self) -> int: """ Spacing Returns ------- value : int """ return self._spacing @spacing.setter def spacing(self, s: int): """ Set spacing Parameters ---------- s : int """ self._spacing = int(s) @property def hovered(self) -> bool: """ Hovered Returns ------- value : bool """ return self._hovered @hovered.setter def hovered(self, h: int): """ Set hovered Parameters ---------- h : int """ self._hovered = bool(h) @property def num_sources(self) -> int: """ N sources Returns ------- value : int """ return self._model.num_ports[PortType.output] @property def num_sinks(self) -> int: """ N sinks Returns ------- value : int """ return self._model.num_ports[PortType.input] @property def dragging_pos(self) -> QPointF: """ Dragging pos Returns ------- value : QPointF """ return self._dragging_pos @dragging_pos.setter def dragging_position(self, pos: QPointF): self._dragging_pos = QPointF(pos) def entry_bounding_rect(self, *, addon=0.0) -> QRectF: """ Entry bounding rect Returns ------- value : QRectF """ return QRectF(0 - addon, 0 - addon, self._entry_width + 2 * addon, self._entry_height + 2 * addon) @property def bounding_rect(self) -> QRectF: """ Bounding rect Returns ------- value : QRectF """ addon = 4 * self._style.connection_point_diameter return QRectF(0 - addon, 0 - addon, self._width + 2 * addon, self._height + 2 * addon) def recalculate_size(self, font: QFont = None): """ If font is unspecified, Updates size unconditionally Otherwise, Updates size if the QFontMetrics is changed """ if font is not None: font_metrics = QFontMetrics(font) bold_font = QFont(font) bold_font.setBold(True) bold_font_metrics = QFontMetrics(bold_font) if self._bold_font_metrics == bold_font_metrics: return self._font_metrics = font_metrics self._bold_font_metrics = bold_font_metrics self._entry_height = self._font_metrics.height() max_num_of_entries = max((self.num_sinks, self.num_sources)) step = self._entry_height + self._spacing height = step * max_num_of_entries widget = self._model.embedded_widget() if widget: height = max((height, widget.height())) height += self.caption_height self._input_port_width = self.port_width(PortType.input) self._output_port_width = self.port_width(PortType.output) width = self._input_port_width + self._output_port_width + 2 * self._spacing if widget: width += widget.width() width = max((width, self.caption_width)) if self._model.validation_state() != NodeValidationState.valid: width = max((width, self.validation_width)) height += self.validation_height + self._spacing self._width = width self._height = height def port_scene_position(self, port_type: PortType, index: int, t: QTransform = None) -> QPointF: """ Port scene position Parameters ---------- port_type : PortType index : int t : QTransform Returns ------- value : QPointF """ if t is None: t = QTransform() step = self._entry_height + self._spacing total_height = float(self.caption_height) + step * index # TODO_UPSTREAM: why? total_height += step / 2.0 if port_type == PortType.output: x = self._width + self._style.connection_point_diameter result = QPointF(x, total_height) elif port_type == PortType.input: x = -float(self._style.connection_point_diameter) result = QPointF(x, total_height) else: raise ValueError(port_type) return t.map(result) def check_hit_scene_point(self, port_type: PortType, scene_point: QPointF, scene_transform: QTransform) -> Port: """ Check hit scene point Parameters ---------- port_type : PortType scene_point : QPointF scene_transform : QTransform Returns ------- value : Port """ if port_type == PortType.none: return None tolerance = 2.0 * self._style.connection_point_diameter for idx, port in self._node.state[port_type].items(): pos = port.get_mapped_scene_position(scene_transform) - scene_point distance = math.sqrt(QPointF.dotProduct(pos, pos)) if distance < tolerance: return port @property def resize_rect(self) -> QRect: """ Resize rect Returns ------- value : QRect """ rect_size = 7 return QRect(self._width - rect_size, self._height - rect_size, rect_size, rect_size) @property def widget_position(self) -> QPointF: """ Returns the position of a widget on the Node surface Returns ------- value : QPointF """ widget = self._model.embedded_widget() if not widget: return QPointF() if widget.sizePolicy().verticalPolicy() & QSizePolicy.ExpandFlag: # If the widget wants to use as much vertical space as possible, # place it immediately after the caption. return QPointF(self._spacing + self.port_width(PortType.input), self.caption_height()) if self._model.validation_state() != NodeValidationState.valid: return QPointF( self._spacing + self.port_width(PortType.input), (self.caption_height + self._height - self.validation_height - self._spacing - widget.height()) / 2.0, ) return QPointF( self._spacing + self.port_width(PortType.input), (self.caption_height + self._height - widget.height()) / 2.0 ) def equivalent_widget_height(self) -> int: ''' The maximum height a widget can be without causing the node to grow. Returns ------- value : int ''' base_height = self.height() - self.caption_height() if self._model.validation_state() != NodeValidationState.valid: return (base_height + self.validation_height()) return base_height @property def validation_height(self) -> int: """ Validation height Returns ------- value : int """ msg = self._model.validation_message() return self._bold_font_metrics.boundingRect(msg).height() @property def validation_width(self) -> int: """ Validation width Returns ------- value : int """ msg = self._model.validation_message() return self._bold_font_metrics.boundingRect(msg).width() @staticmethod def calculate_node_position_between_node_ports( target_port_index: int, target_port: PortType, target_node: NodeBase, source_port_index: int, source_port: PortType, source_node: NodeBase, new_node: NodeBase) -> QPointF: """ calculate node position between node ports Calculating the nodes position in the scene. It'll be positioned half way between the two ports that it "connects". The first line calculates the halfway point between the ports (node position + port position on the node for both nodes averaged). The second line offsets self coordinate with the size of the new node, so that the new nodes center falls on the originally calculated coordinate, instead of it's upper left corner. Parameters ---------- target_port_index : int target_port : PortType target_node : Node source_port_index : int source_port : PortType source_node : Node new_node : Node Returns ------- value : QPointF """ converter_node_pos = ( source_node.graphics_object.pos() + source_node.geometry.port_scene_position(source_port, source_port_index) + target_node.graphics_object.pos() + target_node.geometry.port_scene_position(target_port, target_port_index) ) / 2.0 converter_node_pos.setX(converter_node_pos.x() - new_node.geometry.width / 2.0) converter_node_pos.setY(converter_node_pos.y() - new_node.geometry.height / 2.0) return converter_node_pos @property def caption_height(self) -> int: """ Caption height Returns ------- value : int """ if not self._model.caption_visible: return 0 name = self._model.caption return self._bold_font_metrics.boundingRect(name).height() @property def caption_width(self) -> int: """ Caption width Returns ------- value : int """ if not self._model.caption_visible: return 0 name = self._model.caption return self._bold_font_metrics.boundingRect(name).width() def port_width(self, port_type: PortType) -> int: """ Port width Parameters ---------- port_type : PortType Returns ------- value : int """ names = [port.display_text for port in self._node[port_type].values()] if not names: return 0 return max(self._font_metrics.horizontalAdvance(name) for name in names) @property def size(self): """ Get the node size Parameters ---------- node : Node Returns ------- value : QSizeF """ return QSizeF(self.width, self.height) qtpynodeeditor-0.2.0/qtpynodeeditor/node_graphics_object.py000066400000000000000000000246761372375653600244020ustar00rootroot00000000000000import typing from qtpy.QtCore import QPoint, QRectF, QSize, QSizeF, Qt from qtpy.QtGui import QCursor, QPainter from qtpy.QtWidgets import (QGraphicsDropShadowEffect, QGraphicsItem, QGraphicsObject, QGraphicsProxyWidget, QGraphicsSceneContextMenuEvent, QGraphicsSceneHoverEvent, QGraphicsSceneMouseEvent, QSizePolicy, QStyleOptionGraphicsItem, QWidget) from .enums import ConnectionPolicy from .node_connection_interaction import NodeConnectionInteraction from .port import PortType class NodeGraphicsObject(QGraphicsObject): def __init__(self, scene, node): super().__init__() self._scene = scene self._node = node self._locked = False self._proxy_widget = None self._scene.addItem(self) self.setFlag(QGraphicsItem.ItemDoesntPropagateOpacityToChildren, True) self.setFlag(QGraphicsItem.ItemIsMovable, True) self.setFlag(QGraphicsItem.ItemIsFocusable, True) self.setFlag(QGraphicsItem.ItemIsSelectable, True) self.setFlag(QGraphicsItem.ItemSendsScenePositionChanges, True) self.setCacheMode(QGraphicsItem.DeviceCoordinateCache) self._style = node.model.style node_style = self._style.node effect = QGraphicsDropShadowEffect() effect.setOffset(4, 4) effect.setBlurRadius(20) effect.setColor(node_style.shadow_color) self.setGraphicsEffect(effect) self.setOpacity(node_style.opacity) self.setAcceptHoverEvents(True) self.setZValue(0) self.embed_q_widget() # connect to the move signals to emit the move signals in FlowScene def on_move(): self._scene.node_moved.emit(self._node, self.pos()) self.xChanged.connect(on_move) self.yChanged.connect(on_move) def _cleanup(self): if self._scene is not None: self._scene.removeItem(self) self._scene = None def setPos(self, pos): super().setPos(pos) self.move_connections() @property def node(self): """ Node Returns ------- value : Node """ return self._node def boundingRect(self) -> QRectF: """ boundingRect Returns ------- value : QRectF """ return self._node.geometry.bounding_rect def set_geometry_changed(self): self.prepareGeometryChange() def move_connections(self): """ Visits all attached connections and corrects their corresponding end points. """ for conn in self._node.state.all_connections: conn.graphics_object.move() def lock(self, locked: bool): """ Lock Parameters ---------- locked : bool """ self._locked = locked self.setFlag(QGraphicsItem.ItemIsMovable, not locked) self.setFlag(QGraphicsItem.ItemIsFocusable, not locked) self.setFlag(QGraphicsItem.ItemIsSelectable, not locked) def paint(self, painter: QPainter, option: QStyleOptionGraphicsItem, widget: QWidget): """ Paint Parameters ---------- painter : QPainter option : QStyleOptionGraphicsItem widget : QWidget """ from .node_painter import NodePainter # TODO painter.setClipRect(option.exposedRect) NodePainter.paint(painter, self._node, self._scene, node_style=self._style.node, connection_style=self._style.connection, ) def itemChange(self, change: QGraphicsItem.GraphicsItemChange, value: typing.Any) -> typing.Any: """ itemChange Parameters ---------- change : QGraphicsItem.GraphicsItemChange value : any Returns ------- value : any """ if change == self.ItemPositionChange and self.scene(): self.move_connections() return super().itemChange(change, value) def mousePressEvent(self, event: QGraphicsSceneMouseEvent): """ mousePressEvent Parameters ---------- event : QGraphicsSceneMouseEvent """ if self._locked: return # deselect all other items after self one is selected if not self.isSelected() and not (event.modifiers() & Qt.ControlModifier): self._scene.clearSelection() node_geometry = self._node.geometry for port_to_check in (PortType.input, PortType.output): # TODO do not pass sceneTransform port = node_geometry.check_hit_scene_point(port_to_check, event.scenePos(), self.sceneTransform()) if not port: continue connections = port.connections # start dragging existing connection if connections and port_to_check == PortType.input: conn, = connections interaction = NodeConnectionInteraction(self._node, conn, self._scene) interaction.disconnect(port_to_check) elif port_to_check == PortType.output: # initialize new Connection out_policy = port.connection_policy if connections and out_policy == ConnectionPolicy.one: conn, = connections self._scene.delete_connection(conn) # TODO_UPSTREAM: add to FlowScene connection = self._scene.create_connection(port) connection.graphics_object.grabMouse() pos = QPoint(event.pos().x(), event.pos().y()) geom = self._node.geometry state = self._node.state if self._node.model.resizable() and geom.resize_rect.contains(pos): state.resizing = True def mouseMoveEvent(self, event: QGraphicsSceneMouseEvent): """ mouseMoveEvent Parameters ---------- event : QGraphicsSceneMouseEvent """ geom = self._node.geometry state = self._node.state if state.resizing: diff = event.pos() - event.lastPos() w = self._node.model.embedded_widget() if w: self.prepareGeometryChange() old_size = w.size() + QSize(diff.x(), diff.y()) w.setFixedSize(old_size) old_size_f = QSizeF(old_size) self._proxy_widget.setMinimumSize(old_size_f) self._proxy_widget.setMaximumSize(old_size_f) self._proxy_widget.setPos(geom.widget_position) geom.recalculate_size() self.update() self.move_connections() event.accept() else: super().mouseMoveEvent(event) if event.lastPos() != event.pos(): self.move_connections() event.ignore() bounding = self.mapToScene(self.boundingRect()).boundingRect() r = self.scene().sceneRect().united(bounding) self.scene().setSceneRect(r) def mouseReleaseEvent(self, event: QGraphicsSceneMouseEvent): """ mouseReleaseEvent Parameters ---------- event : QGraphicsSceneMouseEvent """ state = self._node.state state.resizing = False super().mouseReleaseEvent(event) # position connections precisely after fast node move self.move_connections() def hoverEnterEvent(self, event: QGraphicsSceneHoverEvent): """ hoverEnterEvent Parameters ---------- event : QGraphicsSceneHoverEvent """ # void # bring all the colliding nodes to background overlap_items = self.collidingItems() for item in overlap_items: if item.zValue() > 0.0: item.setZValue(0.0) # bring self node forward self.setZValue(1.0) self._node.geometry.hovered = True self.update() self._scene.node_hovered.emit(self._node, event.screenPos()) event.accept() def hoverLeaveEvent(self, event: QGraphicsSceneHoverEvent): """ hoverLeaveEvent Parameters ---------- event : QGraphicsSceneHoverEvent """ self._node.geometry.hovered = False self.update() self._scene.node_hover_left.emit(self._node) event.accept() def hoverMoveEvent(self, event: QGraphicsSceneHoverEvent): """ hoverMoveEvent Parameters ---------- q_graphics_scene_hover_event : QGraphicsSceneHoverEvent """ pos = event.pos() geom = self._node.geometry if (self._node.model.resizable() and geom.resize_rect.contains(QPoint(pos.x(), pos.y()))): self.setCursor(QCursor(Qt.SizeFDiagCursor)) else: self.setCursor(QCursor()) event.accept() def mouseDoubleClickEvent(self, event: QGraphicsSceneMouseEvent): """ mouseDoubleClickEvent Parameters ---------- event : QGraphicsSceneMouseEvent """ super().mouseDoubleClickEvent(event) self._scene.node_double_clicked.emit(self._node) def contextMenuEvent(self, event: QGraphicsSceneContextMenuEvent): """ contextMenuEvent Parameters ---------- event : QGraphicsSceneContextMenuEvent """ self._scene.node_context_menu.emit( self._node, event.scenePos(), event.screenPos()) def embed_q_widget(self): geom = self._node.geometry widget = self._node.model.embedded_widget() if widget is None: return self._proxy_widget = QGraphicsProxyWidget(self) self._proxy_widget.setWidget(widget) self._proxy_widget.setPreferredWidth(5) geom.recalculate_size() # If the widget wants to use as much vertical space as possible, set it # to have the geomtry's equivalent_widget_height. if widget.sizePolicy().verticalPolicy() & QSizePolicy.ExpandFlag: self._proxy_widget.setMinimumHeight(geom.equivalent_widget_height()) self._proxy_widget.setPos(geom.widget_position) self.update() self._proxy_widget.setOpacity(1.0) self._proxy_widget.setFlag(QGraphicsItem.ItemIgnoresParentOpacity) qtpynodeeditor-0.2.0/qtpynodeeditor/node_painter.py000066400000000000000000000264171372375653600227110ustar00rootroot00000000000000import math from qtpy.QtCore import QPointF, QRectF, Qt from qtpy.QtGui import QFontMetrics, QLinearGradient, QPainter, QPen from .base import FlowSceneBase, NodeBase from .enums import NodeValidationState, PortType from .node_data import NodeDataModel from .node_geometry import NodeGeometry from .node_graphics_object import NodeGraphicsObject from .node_state import NodeState from .style import ConnectionStyle, NodeStyle class NodePainterDelegate: def paint(self, painter: QPainter, geom: NodeGeometry, model: NodeDataModel): """ Paint Parameters ---------- painter : QPainter geom : NodeGeometry model : NodeDataModel """ ... class NodePainter: @staticmethod def paint(painter: QPainter, node: NodeBase, scene: FlowSceneBase, node_style: NodeStyle, connection_style: ConnectionStyle): """ Paint Parameters ---------- painter : QPainter node : Node scene : FlowScene node_style : NodeStyle connection_style : ConnectionStyle """ geom = node.geometry state = node.state graphics_object = node.graphics_object geom.recalculate_size(painter.font()) model = node.model NodePainter.draw_node_rect(painter, geom, model, graphics_object, node_style) NodePainter.draw_connection_points(painter, geom, state, model, scene, node_style, connection_style) NodePainter.draw_filled_connection_points(painter, geom, state, model, node_style, connection_style ) NodePainter.draw_model_name(painter, geom, state, model, node_style) NodePainter.draw_entry_labels(painter, geom, state, model, node_style) NodePainter.draw_resize_rect(painter, geom, model) NodePainter.draw_validation_rect(painter, geom, model, graphics_object, node_style) # call custom painter painter_delegate = model.painter_delegate() if painter_delegate: painter_delegate.paint(painter, geom, model) @staticmethod def draw_node_rect(painter: QPainter, geom: NodeGeometry, model: NodeDataModel, graphics_object: NodeGraphicsObject, node_style: NodeStyle): """ Draw node rect Parameters ---------- painter : QPainter geom : NodeGeometry model : NodeDataModel graphics_object : NodeGraphicsObject node_style : NodeStyle """ color = (node_style.selected_boundary_color if graphics_object.isSelected() else node_style.normal_boundary_color ) p = QPen(color, (node_style.hovered_pen_width if geom.hovered else node_style.pen_width)) painter.setPen(p) gradient = QLinearGradient(QPointF(0.0, 0.0), QPointF(2.0, geom.height)) for at_, color in node_style.gradient_colors: gradient.setColorAt(at_, color) painter.setBrush(gradient) diam = node_style.connection_point_diameter boundary = QRectF(-diam, -diam, 2.0 * diam + geom.width, 2.0 * diam + geom.height) radius = 3.0 painter.drawRoundedRect(boundary, radius, radius) @staticmethod def draw_model_name(painter: QPainter, geom: NodeGeometry, state: NodeState, model: NodeDataModel, node_style: NodeStyle): """ Draw model name Parameters ---------- painter : QPainter geom : NodeGeometry state : NodeState model : NodeDataModel """ if not model.caption_visible: return name = model.caption f = painter.font() f.setBold(True) metrics = QFontMetrics(f) rect = metrics.boundingRect(name) position = QPointF((geom.width - rect.width()) / 2.0, (geom.spacing + geom.entry_height) / 3.0) painter.setFont(f) painter.setPen(node_style.font_color) painter.drawText(position, name) f.setBold(False) painter.setFont(f) @staticmethod def draw_entry_labels(painter: QPainter, geom: NodeGeometry, state: NodeState, model: NodeDataModel, node_style: NodeStyle): """ Draw entry labels Parameters ---------- painter : QPainter geom : NodeGeometry state : NodeState model : NodeDataModel node_style : NodeStyle """ metrics = painter.fontMetrics() for port in state.ports: scene_pos = port.scene_position if not port.connections: painter.setPen(node_style.font_color_faded) else: painter.setPen(node_style.font_color) display_text = port.display_text rect = metrics.boundingRect(display_text) scene_pos.setY(scene_pos.y() + rect.height() / 4.0) if port.port_type == PortType.input: scene_pos.setX(5.0) elif port.port_type == PortType.output: scene_pos.setX(geom.width - 5.0 - rect.width()) painter.drawText(scene_pos, display_text) @staticmethod def draw_connection_points(painter: QPainter, geom: NodeGeometry, state: NodeState, model: NodeDataModel, scene: FlowSceneBase, node_style: NodeStyle, connection_style: ConnectionStyle ): """ Draw connection points Parameters ---------- painter : QPainter geom : NodeGeometry state : NodeState model : NodeDataModel scene : FlowScene connection_style : ConnectionStyle """ diameter = node_style.connection_point_diameter reduced_diameter = diameter * 0.6 for port in state.ports: scene_pos = port.scene_position can_connect = port.can_connect port_type = port.port_type data_type = port.data_type r = 1.0 if state.is_reacting and can_connect and port_type == state.reacting_port_type: diff = geom.dragging_pos - scene_pos dist = math.sqrt(QPointF.dotProduct(diff, diff)) registry = scene.registry dtype1, dtype2 = state.reacting_data_type, data_type if port_type != PortType.input: dtype2, dtype1 = dtype1, dtype2 type_convertable = registry.get_type_converter(dtype1, dtype2) is not None if dtype1.id == dtype2.id or type_convertable: thres = 40.0 r = ((2.0 - dist / thres) if dist < thres else 1.0) else: thres = 80.0 r = ((dist / thres) if dist < thres else 1.0) if connection_style.use_data_defined_colors: brush = connection_style.get_normal_color(data_type.id) else: brush = node_style.connection_point_color painter.setBrush(brush) painter.drawEllipse(scene_pos, reduced_diameter * r, reduced_diameter * r) @staticmethod def draw_filled_connection_points(painter: QPainter, geom: NodeGeometry, state: NodeState, model: NodeDataModel, node_style: NodeStyle, connection_style: ConnectionStyle ): """ Draw filled connection points Parameters ---------- painter : QPainter geom : NodeGeometry state : NodeState model : NodeDataModel node_style : NodeStyle connection_style : ConnectionStyle """ diameter = node_style.connection_point_diameter for port in state.ports: if not port.connections: continue scene_pos = port.scene_position if connection_style.use_data_defined_colors: c = connection_style.get_normal_color(port.data_type.id) else: c = node_style.filled_connection_point_color painter.setPen(c) painter.setBrush(c) painter.drawEllipse(scene_pos, diameter * 0.4, diameter * 0.4) @staticmethod def draw_resize_rect(painter: QPainter, geom: NodeGeometry, model: NodeDataModel): """ Draw resize rect Parameters ---------- painter : QPainter geom : NodeGeometry model : NodeDataModel """ if model.resizable(): painter.setBrush(Qt.gray) painter.drawEllipse(geom.resize_rect) @staticmethod def draw_validation_rect(painter: QPainter, geom: NodeGeometry, model: NodeDataModel, graphics_object: NodeGraphicsObject, node_style: NodeStyle): """ Draw validation rect Parameters ---------- painter : QPainter geom : NodeGeometry model : NodeDataModel graphics_object : NodeGraphicsObject node_style : NodeStyle """ model_validation_state = model.validation_state() if model_validation_state == NodeValidationState.valid: return color = (node_style.selected_boundary_color if graphics_object.isSelected() else node_style.normal_boundary_color) if geom.hovered: p = QPen(color, node_style.hovered_pen_width) else: p = QPen(color, node_style.pen_width) painter.setPen(p) # Drawing the validation message background if model_validation_state == NodeValidationState.error: painter.setBrush(node_style.error_color) else: painter.setBrush(node_style.warning_color) radius = 3.0 diam = node_style.connection_point_diameter boundary = QRectF( -diam, -diam + geom.height - geom.validation_height, 2.0 * diam + geom.width, 2.0 * diam + geom.validation_height, ) painter.drawRoundedRect(boundary, radius, radius) painter.setBrush(Qt.gray) # Drawing the validation message itself error_msg = model.validation_message() f = painter.font() metrics = QFontMetrics(f) rect = metrics.boundingRect(error_msg) position = QPointF( (geom.width - rect.width()) / 2.0, geom.height - (geom.validation_height - diam) / 2.0 ) painter.setFont(f) painter.setPen(node_style.font_color) painter.drawText(position, error_msg) qtpynodeeditor-0.2.0/qtpynodeeditor/node_state.py000066400000000000000000000104211372375653600223530ustar00rootroot00000000000000from collections import OrderedDict from .base import ConnectionBase from .enums import ReactToConnectionState from .node_data import NodeDataType from .port import Port, PortType class NodeState: def __init__(self, node): ''' node_state Parameters ---------- model : NodeDataModel ''' self._ports = {PortType.input: OrderedDict(), PortType.output: OrderedDict() } model = node.model for port_type in self._ports: num_ports = model.num_ports[port_type] self._ports[port_type] = OrderedDict( (i, Port(node, port_type=port_type, index=i)) for i in range(num_ports) ) self._reaction = ReactToConnectionState.not_reacting self._reacting_port_type = PortType.none self._reacting_data_type = None self._resizing = False def __getitem__(self, key): return self._ports[key] @property def ports(self): yield from self.input_ports yield from self.output_ports @property def input_ports(self): yield from self[PortType.input].values() @property def output_ports(self): yield from self[PortType.output].values() @property def output_connections(self): """All output connections""" return [ connection for idx, port in self._ports[PortType.output].items() for connection in port.connections ] @property def input_connections(self): """All input connections""" return [ connection for idx, port in self._ports[PortType.input].items() for connection in port.connections ] @property def all_connections(self): """All input and output connections""" return self.input_connections + self.output_connections def connections(self, port_type: PortType, port_index: int) -> list: """ Connections Parameters ---------- port_type : PortType port_index : int Returns ------- value : list """ return list(self._ports[port_type][port_index].connections) def erase_connection(self, port_type: PortType, port_index: int, connection: ConnectionBase): """ Erase connection Parameters ---------- port_type : PortType port_index : int connection : Connection """ self._ports[port_type][port_index].remove_connection(connection) @property def reaction(self) -> ReactToConnectionState: """ Reaction Returns ------- value : NodeState.ReactToConnectionState """ return self._reaction @property def reacting_port_type(self) -> PortType: """ Reacting port type Returns ------- value : PortType """ return self._reacting_port_type @property def reacting_data_type(self) -> NodeDataType: """ Reacting data type Returns ------- value : NodeDataType """ return self._reacting_data_type def set_reaction(self, reaction: ReactToConnectionState, reacting_port_type: PortType = PortType.none, reacting_data_type: NodeDataType = None): """ Set reaction Parameters ---------- reaction : NodeState.ReactToConnectionState reacting_port_type : PortType, optional reacting_data_type : NodeDataType """ self._reaction = ReactToConnectionState(reaction) self._reacting_port_type = reacting_port_type self._reacting_data_type = reacting_data_type @property def is_reacting(self) -> bool: """ Is the node reacting to a mouse event? Returns ------- value : bool """ return self._reaction == ReactToConnectionState.reacting @property def resizing(self) -> bool: """ Resizing Returns ------- value : bool """ return self._resizing @resizing.setter def resizing(self, resizing: bool): self._resizing = resizing qtpynodeeditor-0.2.0/qtpynodeeditor/port.py000066400000000000000000000076771372375653600212350ustar00rootroot00000000000000from qtpy.QtCore import QObject, Signal from .base import ConnectionBase from .enums import ConnectionPolicy, PortType def opposite_port(port: PortType): return {PortType.input: PortType.output, PortType.output: PortType.input}.get(port, PortType.none) class Port(QObject): connection_created = Signal(ConnectionBase) connection_deleted = Signal(ConnectionBase) data_updated = Signal(QObject) data_invalidated = Signal(QObject) def __init__(self, node, *, port_type: PortType, index: int): super().__init__(parent=node) self.node = node self.port_type = port_type self.index = index self._connections = [] self.opposite_port = {PortType.input: PortType.output, PortType.output: PortType.input}[self.port_type] @property def connections(self): return list(self._connections) @property def model(self): 'The data model associated with the Port' return self.node.model @property def data(self): 'The NodeData associated with the Port, if an output port' if self.port_type == PortType.input: # return self.model.in_data(self.index) # TODO return else: return self.model.out_data(self.index) @property def can_connect(self): 'Can this port be connected to?' return (not self._connections or self.connection_policy == ConnectionPolicy.many) @property def caption(self): 'Data model-specified caption for the port' return self.model.port_caption[self.port_type][self.index] @property def caption_visible(self): 'Show the data model-specified caption?' return self.model.port_caption_visible[self.port_type][self.index] @property def data_type(self): 'The NodeData type associated with the Port' return self.model.data_type[self.port_type][self.index] @property def display_text(self): 'The text to show on the label caption' return (self.caption if self.caption_visible else self.data_type.name) @property def connection_policy(self): 'The connection policy (one/many) for the port' if self.port_type == PortType.input: return ConnectionPolicy.one else: return self.model.port_out_connection_policy(self.index) def add_connection(self, connection: ConnectionBase): 'Add a Connection to the Port' if connection in self._connections: raise ValueError('Connection already in list') self._connections.append(connection) self.connection_created.emit(connection) def remove_connection(self, connection: ConnectionBase): 'Remove a Connection from the Port' try: self._connections.remove(connection) except ValueError: # TODO: should not be reaching this ... else: self.connection_deleted.emit(connection) @property def scene_position(self): ''' The position in the scene of the Port Returns ------- value : QPointF See also -------- get_mapped_scene_position ''' return self.node.geometry.port_scene_position(self.port_type, self.index) def get_mapped_scene_position(self, transform): """ Node port scene position after a transform Parameters ---------- port_type : PortType port_index : int Returns ------- value : QPointF """ ngo = self.node.graphics_object return ngo.sceneTransform().map(self.scene_position) def __repr__(self): return (f'<{self.__class__.__name__} port_type={self.port_type} ' f'index={self.index} connections={len(self._connections)}>') qtpynodeeditor-0.2.0/qtpynodeeditor/style.py000066400000000000000000000176621372375653600214040ustar00rootroot00000000000000import json import logging import random from qtpy.QtGui import QColor logger = logging.getLogger(__name__) def _get_qcolor(style_dict, key): if key not in style_dict: return QColor() name_or_list = style_dict[key] if isinstance(name_or_list, list): color = QColor(*name_or_list) else: color = QColor(name_or_list) logger.debug('Loaded color %s = %s -> %d %d %d %d', key, name_or_list, *color.getRgb()) return color class Style: default_style = { "FlowViewStyle": { "BackgroundColor": [53, 53, 53], "FineGridColor": [60, 60, 60], "CoarseGridColor": [25, 25, 25] }, "NodeStyle": { "NormalBoundaryColor": [255, 255, 255], "SelectedBoundaryColor": [255, 165, 0], "GradientColor0": "gray", "GradientColor1": [80, 80, 80], "GradientColor2": [64, 64, 64], "GradientColor3": [58, 58, 58], "ShadowColor": [20, 20, 20], "FontColor": "white", "FontColorFaded": "gray", "ConnectionPointColor": [169, 169, 169], "FilledConnectionPointColor": "cyan", "ErrorColor": "red", "WarningColor": [128, 128, 0], "PenWidth": 1.0, "HoveredPenWidth": 1.5, "ConnectionPointDiameter": 8.0, "Opacity": 0.8 }, "ConnectionStyle": { "ConstructionColor": "gray", "NormalColor": "darkcyan", "SelectedColor": [100, 100, 100], "SelectedHaloColor": "orange", "HoveredColor": "lightcyan", "LineWidth": 3.0, "ConstructionLineWidth": 2.0, "PointDiameter": 10.0, "UseDataDefinedColors": False } } def __init__(self, json_style=None): if json_style is None: json_style = self.default_style self.load_from_json(json_style) def load_from_json(self, json_style: str): """ Load from json Parameters ---------- json_style : str or dict """ if isinstance(json_style, dict): return json_style else: return json.loads(json_style) class FlowViewStyle(Style): def __init__(self, json_style=None): self.background_color = QColor() self.fine_grid_color = QColor() self.coarse_grid_color = QColor() super().__init__(json_style=json_style) def load_from_json(self, json_style: str): """ Load from json Parameters ---------- json_style : str or dict """ doc = super().load_from_json(json_style) style = doc["FlowViewStyle"] self.background_color = _get_qcolor(style, 'BackgroundColor') self.fine_grid_color = _get_qcolor(style, 'FineGridColor') self.coarse_grid_color = _get_qcolor(style, 'CoarseGridColor') class ConnectionStyle(Style): ''' Style for connections Attributes ---------- construction_color : QColor normal_color : QColor selected_color : QColor selected_halo_color : QColor hovered_color : QColor line_width : float construction_line_width : float point_diameter : float use_data_defined_colors : bool ''' def __init__(self, json_style=None): self.construction_color = QColor() self.normal_color = QColor() self.selected_color = QColor() self.selected_halo_color = QColor() self.hovered_color = QColor() self.line_width = 0.0 self.construction_line_width = 0.0 self.point_diameter = 0.0 self.use_data_defined_colors = True super().__init__(json_style=json_style) def load_from_json(self, json_style: str): """ Load from json Parameters ---------- json_style : str """ doc = super().load_from_json(json_style) style = doc["ConnectionStyle"] self.construction_color = _get_qcolor(style, 'ConstructionColor') self.normal_color = _get_qcolor(style, 'NormalColor') self.selected_color = _get_qcolor(style, 'SelectedColor') self.selected_halo_color = _get_qcolor(style, 'SelectedHaloColor') self.hovered_color = _get_qcolor(style, 'HoveredColor') self.line_width = float(style['LineWidth']) self.construction_line_width = float(style['ConstructionLineWidth']) self.point_diameter = float(style['PointDiameter']) self.use_data_defined_colors = bool(style['UseDataDefinedColors']) def get_normal_color(self, type_id: str = None) -> QColor: """ Normal color Parameters ---------- type_id : str Returns ------- value : QColor """ if type_id is None: return self.normal_color hue_range = 0xFF random.seed(type_id) hue = random.randint(0, hue_range) sat = 120 + id(type_id) % 129 return QColor.fromHsl(hue, sat, 160) class NodeStyle(Style): def __init__(self, json_style=None): self.normal_boundary_color = QColor() self.selected_boundary_color = QColor() self.gradient_colors = ((0, QColor()), ) self.shadow_color = QColor() self.font_color = QColor() self.font_color_faded = QColor() self.connection_point_color = QColor() self.filled_connection_point_color = QColor() self.warning_color = QColor() self.error_color = QColor() self.pen_width = 1.0 self.hovered_pen_width = 2.0 self.connection_point_diameter = 5.0 self.opacity = 1.0 super().__init__(json_style=json_style) def load_from_json(self, json_style: str): """ Load from json Parameters ---------- json_style : str """ doc = super().load_from_json(json_style) style = doc["NodeStyle"] self.normal_boundary_color = _get_qcolor(style, 'NormalBoundaryColor') self.selected_boundary_color = _get_qcolor( style, 'SelectedBoundaryColor') self.gradient_colors = ( (0.0, _get_qcolor(style, 'GradientColor0')), (0.03, _get_qcolor(style, 'GradientColor1')), (0.97, _get_qcolor(style, 'GradientColor2')), (1.0, _get_qcolor(style, 'GradientColor3')), ) self.shadow_color = _get_qcolor(style, 'ShadowColor') self.font_color = _get_qcolor(style, 'FontColor') self.font_color_faded = _get_qcolor(style, 'FontColorFaded') self.connection_point_color = _get_qcolor( style, 'ConnectionPointColor') self.filled_connection_point_color = _get_qcolor( style, 'FilledConnectionPointColor') self.warning_color = _get_qcolor(style, 'WarningColor') self.error_color = _get_qcolor(style, 'ErrorColor') self.pen_width = float(style['PenWidth']) self.hovered_pen_width = float(style['HoveredPenWidth']) self.connection_point_diameter = float( style['ConnectionPointDiameter']) self.opacity = float(style['Opacity']) class StyleCollection: 'Container for all styles' def __init__(self, *, node=None, connection=None, flow_view=None): if node is None: node = NodeStyle() self.node = node if connection is None: connection = ConnectionStyle() self.connection = connection if flow_view is None: flow_view = FlowViewStyle() self.flow_view = flow_view @classmethod def from_json(cls, json_doc): if isinstance(json_doc, dict): json_style = json_doc else: json_style = json.loads(json_doc) return StyleCollection( node=NodeStyle(json_style), connection=ConnectionStyle(json_style), flow_view=FlowViewStyle(json_style), ) default_style = StyleCollection() qtpynodeeditor-0.2.0/qtpynodeeditor/tests/000077500000000000000000000000001372375653600210205ustar00rootroot00000000000000qtpynodeeditor-0.2.0/qtpynodeeditor/tests/__init__.py000066400000000000000000000000001372375653600231170ustar00rootroot00000000000000qtpynodeeditor-0.2.0/qtpynodeeditor/tests/conftest.py000066400000000000000000000001041372375653600232120ustar00rootroot00000000000000import pytest # noqa from pytestqt.qt_compat import qt_api # noqa qtpynodeeditor-0.2.0/qtpynodeeditor/tests/test_basic.py000066400000000000000000000277301372375653600235230ustar00rootroot00000000000000import unittest.mock import pytest import qtpy.QtCore import qtpynodeeditor as nodeeditor from qtpynodeeditor import PortType class MyNodeData(nodeeditor.NodeData): data_type = nodeeditor.NodeDataType('MyNodeData', 'My Node Data') class MyOtherNodeData(nodeeditor.NodeData): data_type = nodeeditor.NodeDataType('MyOtherNodeData', 'My Other Node Data') class BasicDataModel(nodeeditor.NodeDataModel): name = 'MyDataModel' caption = 'Caption' caption_visible = True num_ports = {'input': 3, 'output': 3 } data_type = MyNodeData.data_type def model(self): return 'MyDataModel' def out_data(self, port_index): return MyNodeData() def set_in_data(self, node_data, port): ... def embedded_widget(self): return None class BasicOtherDataModel(nodeeditor.NodeDataModel): name = 'MyOtherDataModel' caption = 'Caption' caption_visible = True num_ports = {'input': 1, 'output': 1 } data_type = MyOtherNodeData.data_type # @pytest.mark.parametrize("model_class", [...]) @pytest.fixture(scope='function') def model(): return BasicDataModel @pytest.fixture(scope='function') def other_model(): return BasicOtherDataModel @pytest.fixture(scope='function') def registry(model, other_model): registry = nodeeditor.DataModelRegistry() registry.register_model(model, category='My Category') registry.register_model(other_model, category='My Category') return registry @pytest.fixture(scope='function') def scene(qapp, registry): return nodeeditor.FlowScene(registry=registry) @pytest.fixture(scope='function') def view(qtbot, scene): view = nodeeditor.FlowView(scene) qtbot.addWidget(view) view.setWindowTitle("nodeeditor test suite") view.resize(800, 600) view.show() return view def test_instantiation(view): ... def test_create_node(scene, model): node = scene.create_node(model) assert node in scene.nodes.values() assert node.id in scene.nodes assert scene.allow_node_creation assert scene.allow_node_deletion def test_selected_nodes(scene, model): node = scene.create_node(model) node.graphics_object.setSelected(True) assert scene.selected_nodes() == [node] def test_create_connection(scene, view, model): node1 = scene.create_node(model) node2 = scene.create_node(model) scene.create_connection(node2[PortType.output][2], node1[PortType.input][1], ) view.update() assert len(scene.connections) == 1 all_c1 = node1.state.all_connections assert len(all_c1) == 1 all_c2 = node1.state.all_connections assert len(all_c2) == 1 assert all_c1 == all_c2 conn, = all_c1 # conn_state = conn.state in_node = conn.get_node(PortType.input) in_port = conn.get_port_index(PortType.input) out_node = conn.get_node(PortType.output) out_port = conn.get_port_index(PortType.output) assert in_node == node1 assert in_port == 1 assert out_node == node2 assert out_port == 2 scene.delete_connection(conn) assert len(scene.connections) == 0 all_c1 = node1.state.all_connections assert len(all_c1) == 0 all_c2 = node1.state.all_connections assert len(all_c2) == 0 def test_create_connection_with_converter(scene, view, model, other_model): node1 = scene.create_node(model) node2 = scene.create_node(other_model) # Converter not registerd, must raise Exception with pytest.raises(nodeeditor.ConnectionDataTypeFailure): scene.create_connection(node1[PortType.output][0], node2[PortType.input][0]) # Wrong converter, must fail converter = nodeeditor.type_converter.TypeConverter(MyOtherNodeData.data_type, MyNodeData.data_type, lambda x: None) scene.registry.register_type_converter(MyNodeData.data_type, MyOtherNodeData.data_type, converter) with pytest.raises(nodeeditor.ConnectionDataTypeFailure): scene.create_connection(node1[PortType.output][0], node2[PortType.input][0]) # Correct converter registered, must pass converter = nodeeditor.type_converter.TypeConverter(MyNodeData.data_type, MyOtherNodeData.data_type, lambda x: None) scene.registry.register_type_converter(MyNodeData.data_type, MyOtherNodeData.data_type, converter) scene.create_connection(node1[PortType.output][0], node2[PortType.input][0]) def test_clear_scene(scene, view, model): node1 = scene.create_node(model) node2 = scene.create_node(model) scene.create_connection(node2[PortType.output][2], node1[PortType.input][1], ) scene.clear_scene() assert len(scene.nodes) == 0 assert len(scene.connections) == 0 all_c1 = node1.state.all_connections assert len(all_c1) == 0 all_c2 = node1.state.all_connections assert len(all_c2) == 0 def test_get_and_set_state(scene, model): node1 = scene.create_node(model) node2 = scene.create_node(model) scene.create_connection(node2[PortType.output][2], node1[PortType.input][1], ) state = scene.__getstate__() scene.__setstate__(state) assert scene.__getstate__() == state def test_save_load(tmp_path, scene, view, model): node1 = scene.create_node(model) node2 = scene.create_node(model) created_nodes = (node1, node2) assert len(scene.nodes) == len(created_nodes) for node in created_nodes: assert node in scene.nodes.values() assert node.id in scene.nodes fname = tmp_path / 'temp.flow' scene.save(fname) scene.load(fname) assert len(scene.nodes) == len(created_nodes) for node in created_nodes: assert node not in scene.nodes.values() assert node.id in scene.nodes @pytest.mark.parametrize('reset, port_type', [(True, 'input'), (False, 'output')]) def test_smoke_reacting(scene, view, model, reset, port_type): node = scene.create_node(model) dtype = node.model.data_type[port_type][0] node.react_to_possible_connection( reacting_port_type=port_type, reacting_data_type=dtype, scene_point=qtpy.QtCore.QPointF(0, 0), ) view.update() if reset: node.reset_reaction_to_connection() def test_smoke_node_size_updated(scene, view, model): node = scene.create_node(model) node.on_node_size_updated() view.update() def test_connection_cycles(scene, view, model): node1 = scene.create_node(model) node2 = scene.create_node(model) node3 = scene.create_node(model) scene.create_connection(node1[PortType.output][0], node2[PortType.input][0]) scene.create_connection(node2[PortType.output][0], node3[PortType.input][0]) # node1 -> node2 -> node3 # Test with a fully-specified connection: try to connect node3->node1 with pytest.raises(nodeeditor.ConnectionCycleFailure): scene.create_connection(node3[PortType.output][0], node1[PortType.input][0]) # Test with a half-specified connection: start with node3 conn = scene.create_connection(node3[PortType.output][0]) # and then pretend the user attempts to connect it to node1: interaction = nodeeditor.NodeConnectionInteraction( node=node1, connection=conn, scene=scene) assert interaction.creates_cycle def test_smoke_connection_interaction(scene, view, model): node1 = scene.create_node(model) node2 = scene.create_node(model) conn = scene.create_connection(node1[PortType.output][0]) interaction = nodeeditor.NodeConnectionInteraction( node=node2, connection=conn, scene=scene) node_scene_transform = node2.graphics_object.sceneTransform() pos = node2.geometry.port_scene_position(PortType.input, 0, node_scene_transform) conn.geometry.set_end_point(PortType.input, pos) with pytest.raises(nodeeditor.ConnectionPointFailure): interaction.can_connect() conn.geometry.set_end_point(PortType.output, pos) with pytest.raises(nodeeditor.ConnectionPointFailure): interaction.can_connect() assert interaction.node_port_is_empty(PortType.input, 0) assert interaction.connection_required_port == PortType.input # TODO node still not on it? interaction.can_connect = lambda: (node1.state[PortType.input][0], None) assert interaction.try_connect() interaction.disconnect(PortType.output) interaction.connection_end_scene_position(PortType.input) interaction.node_port_scene_position(PortType.input, 0) interaction.node_port_under_scene_point(PortType.input, qtpy.QtCore.QPointF(0, 0)) def test_connection_interaction_wrong_data_type(scene, view, model, other_model): node1 = scene.create_node(model) node2 = scene.create_node(other_model) conn = scene.create_connection(node1[PortType.output][0]) interaction = nodeeditor.NodeConnectionInteraction( node=node2, connection=conn, scene=scene) node_scene_transform = node2.graphics_object.sceneTransform() pos = node2.geometry.port_scene_position(PortType.input, 0, node_scene_transform) conn.geometry.set_end_point(PortType.input, conn.graphics_object.mapFromScene(pos)) with pytest.raises(nodeeditor.ConnectionDataTypeFailure): interaction.can_connect() def test_locate_node(scene, view, model): node = scene.create_node(model) assert scene.locate_node_at(node.position, view.transform()) == node def test_view_scale(scene, view, model): node1 = scene.create_node(model) node2 = scene.create_node(model) scene.create_connection(node2[PortType.output][2], node1[PortType.input][1], ) view.scale_up() view.scale_down() def test_view_delete_selected(scene, view, model): node1 = scene.create_node(model) node2 = scene.create_node(model) conn = scene.create_connection(node2[PortType.output][2], node1[PortType.input][1]) node1.graphics_object.setSelected(True) conn.graphics_object.setSelected(True) node2.graphics_object.setSelected(True) view.delete_selected() assert node1 not in scene.nodes.values() assert node2 not in scene.nodes.values() assert conn not in scene.connections def test_smoke_view_context_menu(qtbot, view): view.generate_context_menu(qtpy.QtCore.QPoint(0, 0)) def test_smoke_repr(scene, view, model): node1 = scene.create_node(model) node2 = scene.create_node(model) print() print('node1', node1) print('node2', node2) ports = (node2[PortType.output][2], node1[PortType.input][1]) print() print('ports', ports) conn = scene.create_connection(*ports) print() print('connection', conn) def test_smoke_scene_signal_connections(scene, view, model): mock = unittest.mock.Mock() scene.connection_created.connect(mock) node1 = scene.create_node(model) node2 = scene.create_node(model) conn = scene.create_connection(node2[PortType.output][2], node1[PortType.input][1]) assert mock.call_count == 1 mock = unittest.mock.Mock() scene.connection_deleted.connect(mock) node1 = scene.create_node(model) node2 = scene.create_node(model) scene.delete_connection(conn) assert mock.call_count == 1 def test_smoke_scene_signal_nodes(scene, view, model): mock = unittest.mock.Mock() scene.node_created.connect(mock) node1 = scene.create_node(model) node2 = scene.create_node(model) assert mock.call_count == 2 mock = unittest.mock.Mock() scene.node_deleted.connect(mock) scene.remove_node(node1) scene.remove_node(node2) assert mock.call_count == 2 qtpynodeeditor-0.2.0/qtpynodeeditor/tests/test_examples.py000066400000000000000000000053371372375653600242570ustar00rootroot00000000000000import pytest from qtpy import QtCore, QtGui from qtpynodeeditor import examples @pytest.fixture(scope='function', params=['style', 'calculator', 'connection_colors', 'image']) def example(qtbot, qapp, request): example_module = getattr(examples, request.param) scene, view, nodes = example_module.main(qapp) qtbot.addWidget(view) yield scene, view, nodes @pytest.fixture(scope='function') def scene(example): return example[0] @pytest.fixture(scope='function') def view(example): return example[1] @pytest.fixture(scope='function') def nodes(example): return example[2] def test_smoke_example(example): ... def test_iterate(scene): for node in scene.iterate_over_nodes(): print(node.size) node.position = node.position print('Node data iterator') print('------------------') for data in scene.iterate_over_node_data(): print(data, data.number if hasattr(data, 'number') else '') print('Node data dependent iterator') print('----------------------------') for data in scene.iterate_over_node_data_dependent_order(): print(data, data.number if hasattr(data, 'number') else '') def test_smoke_zero_inputs(scene, example): for node in scene.iterate_over_nodes(): widget = node.model.embedded_widget() if widget is not None: if hasattr(widget, 'setText'): widget.setText('0.0') class MySceneEvent(QtGui.QMouseEvent): last_pos = QtCore.QPoint(0, 0) scene_pos = QtCore.QPoint(0, 0) def lastPos(self): return self.last_pos def screenPos(self): return self.scene_pos def scenePos(self): return self.scene_pos def test_smoke_mouse(qtbot, nodes): for node in nodes: ngo = node.graphics_object # TODO qtbot doesn't work with QGraphicsObjects # qtbot.mouseClick(ngo) ev = MySceneEvent(QtCore.QEvent.MouseButtonPress, QtCore.QPointF(0, 0), QtCore.Qt.LeftButton, QtCore.Qt.LeftButton, QtCore.Qt.NoModifier) if node.model.num_ports['input']: pos = node.geometry.port_scene_position('input', 0) else: pos = node.geometry.port_scene_position('output', 0) ev.scene_pos = QtCore.QPoint(pos.x(), pos.y()) ev.last_pos = QtCore.QPoint(pos.x(), pos.y()) if node.model.resizable(): # Other case will try to propagate to mouseMoveEvent node.state.resizing = True ngo.mouseMoveEvent(ev) ngo.mousePressEvent(ev) ngo.hoverEnterEvent(ev) ngo.hoverMoveEvent(ev) ngo.hoverLeaveEvent(ev) def test_save_and_load(scene): scene.__setstate__(scene.__getstate__()) qtpynodeeditor-0.2.0/qtpynodeeditor/type_converter.py000066400000000000000000000010121372375653600232720ustar00rootroot00000000000000class TypeConverterId: def __init__(self, type_in, type_out): self.type_in = type_in self.type_out = type_out class TypeConverter(TypeConverterId): def __init__(self, type_in, type_out, func): self.type_in = type_in self.type_out = type_out self.id = TypeConverterId(type_in, type_out) self.func = func def __call__(self, input): return self.func(input) def _convert(arg): return arg DefaultTypeConverter = TypeConverter(None, None, _convert) qtpynodeeditor-0.2.0/requirements.txt000066400000000000000000000000311372375653600200620ustar00rootroot00000000000000coverage versioneer qtpy qtpynodeeditor-0.2.0/setup.cfg000066400000000000000000000002251372375653600164240ustar00rootroot00000000000000[versioneer] VCS = git style = pep440 versionfile_source = qtpynodeeditor/_version.py versionfile_build = qtpynodeeditor/_version.py tag_prefix = v qtpynodeeditor-0.2.0/setup.py000066400000000000000000000034731372375653600163250ustar00rootroot00000000000000import pathlib import sys from os import path from setuptools import find_packages, setup import versioneer min_version = (3, 6) if sys.version_info < min_version: error = """ qtpynodeeditor does not support Python {0}.{1}. Python {2}.{3} and above is required. Check your Python version like so: python3 --version This may be due to an out-of-date pip. Make sure you have pip >= 9.0.1. Upgrade pip like so: pip install --upgrade pip """.format(*sys.version_info[:2], *min_version) sys.exit(error) here = pathlib.Path(__file__).parent.absolute() with open(here / 'README.rst', encoding='utf-8') as readme_file: readme = readme_file.read() with open(here / 'requirements.txt', 'rt') as requirements_file: # Parse requirements.txt, ignoring any commented-out lines. requirements = [line for line in requirements_file.read().splitlines() if not line.startswith('#')] setup( name='qtpynodeeditor', version=versioneer.get_version(), cmdclass=versioneer.get_cmdclass(), license='BSD', author='Ken Lauer', packages=find_packages(exclude=['docs', 'tests']), description='Python Qt node editor', long_description=readme, url='https://github.com/klauer/qtpynodeeditor', entry_points={ 'console_scripts': [ # 'some.module:some_function', ], }, include_package_data=True, package_data={ 'qtpynodeeditor': [ # When adding files here, remember to update MANIFEST.in as well, # or else they will not be included in the distribution on PyPI! 'qtpynodeeditor/DefaultStyle.json', ] }, install_requires=requirements, classifiers=[ 'Development Status :: 2 - Pre-Alpha', 'Natural Language :: English', 'Programming Language :: Python :: 3', ], ) qtpynodeeditor-0.2.0/versioneer.py000066400000000000000000002060031372375653600173400ustar00rootroot00000000000000 # Version: 0.18 """The Versioneer - like a rocketeer, but for versions. The Versioneer ============== * like a rocketeer, but for versions! * https://github.com/warner/python-versioneer * Brian Warner * License: Public Domain * Compatible With: python2.6, 2.7, 3.2, 3.3, 3.4, 3.5, 3.6, and pypy * [![Latest Version] (https://pypip.in/version/versioneer/badge.svg?style=flat) ](https://pypi.python.org/pypi/versioneer/) * [![Build Status] (https://travis-ci.org/warner/python-versioneer.png?branch=master) ](https://travis-ci.org/warner/python-versioneer) This is a tool for managing a recorded version number in distutils-based python projects. The goal is to remove the tedious and error-prone "update the embedded version string" step from your release process. Making a new release should be as easy as recording a new tag in your version-control system, and maybe making new tarballs. ## Quick Install * `pip install versioneer` to somewhere to your $PATH * add a `[versioneer]` section to your setup.cfg (see below) * run `versioneer install` in your source tree, commit the results ## Version Identifiers Source trees come from a variety of places: * a version-control system checkout (mostly used by developers) * a nightly tarball, produced by build automation * a snapshot tarball, produced by a web-based VCS browser, like github's "tarball from tag" feature * a release tarball, produced by "setup.py sdist", distributed through PyPI Within each source tree, the version identifier (either a string or a number, this tool is format-agnostic) can come from a variety of places: * ask the VCS tool itself, e.g. "git describe" (for checkouts), which knows about recent "tags" and an absolute revision-id * the name of the directory into which the tarball was unpacked * an expanded VCS keyword ($Id$, etc) * a `_version.py` created by some earlier build step For released software, the version identifier is closely related to a VCS tag. Some projects use tag names that include more than just the version string (e.g. "myproject-1.2" instead of just "1.2"), in which case the tool needs to strip the tag prefix to extract the version identifier. For unreleased software (between tags), the version identifier should provide enough information to help developers recreate the same tree, while also giving them an idea of roughly how old the tree is (after version 1.2, before version 1.3). Many VCS systems can report a description that captures this, for example `git describe --tags --dirty --always` reports things like "0.7-1-g574ab98-dirty" to indicate that the checkout is one revision past the 0.7 tag, has a unique revision id of "574ab98", and is "dirty" (it has uncommitted changes. The version identifier is used for multiple purposes: * to allow the module to self-identify its version: `myproject.__version__` * to choose a name and prefix for a 'setup.py sdist' tarball ## Theory of Operation Versioneer works by adding a special `_version.py` file into your source tree, where your `__init__.py` can import it. This `_version.py` knows how to dynamically ask the VCS tool for version information at import time. `_version.py` also contains `$Revision$` markers, and the installation process marks `_version.py` to have this marker rewritten with a tag name during the `git archive` command. As a result, generated tarballs will contain enough information to get the proper version. To allow `setup.py` to compute a version too, a `versioneer.py` is added to the top level of your source tree, next to `setup.py` and the `setup.cfg` that configures it. This overrides several distutils/setuptools commands to compute the version when invoked, and changes `setup.py build` and `setup.py sdist` to replace `_version.py` with a small static file that contains just the generated version data. ## Installation See [INSTALL.md](./INSTALL.md) for detailed installation instructions. ## Version-String Flavors Code which uses Versioneer can learn about its version string at runtime by importing `_version` from your main `__init__.py` file and running the `get_versions()` function. From the "outside" (e.g. in `setup.py`), you can import the top-level `versioneer.py` and run `get_versions()`. Both functions return a dictionary with different flavors of version information: * `['version']`: A condensed version string, rendered using the selected style. This is the most commonly used value for the project's version string. The default "pep440" style yields strings like `0.11`, `0.11+2.g1076c97`, or `0.11+2.g1076c97.dirty`. See the "Styles" section below for alternative styles. * `['full-revisionid']`: detailed revision identifier. For Git, this is the full SHA1 commit id, e.g. "1076c978a8d3cfc70f408fe5974aa6c092c949ac". * `['date']`: Date and time of the latest `HEAD` commit. For Git, it is the commit date in ISO 8601 format. This will be None if the date is not available. * `['dirty']`: a boolean, True if the tree has uncommitted changes. Note that this is only accurate if run in a VCS checkout, otherwise it is likely to be False or None * `['error']`: if the version string could not be computed, this will be set to a string describing the problem, otherwise it will be None. It may be useful to throw an exception in setup.py if this is set, to avoid e.g. creating tarballs with a version string of "unknown". Some variants are more useful than others. Including `full-revisionid` in a bug report should allow developers to reconstruct the exact code being tested (or indicate the presence of local changes that should be shared with the developers). `version` is suitable for display in an "about" box or a CLI `--version` output: it can be easily compared against release notes and lists of bugs fixed in various releases. The installer adds the following text to your `__init__.py` to place a basic version in `YOURPROJECT.__version__`: from ._version import get_versions __version__ = get_versions()['version'] del get_versions ## Styles The setup.cfg `style=` configuration controls how the VCS information is rendered into a version string. The default style, "pep440", produces a PEP440-compliant string, equal to the un-prefixed tag name for actual releases, and containing an additional "local version" section with more detail for in-between builds. For Git, this is TAG[+DISTANCE.gHEX[.dirty]] , using information from `git describe --tags --dirty --always`. For example "0.11+2.g1076c97.dirty" indicates that the tree is like the "1076c97" commit but has uncommitted changes (".dirty"), and that this commit is two revisions ("+2") beyond the "0.11" tag. For released software (exactly equal to a known tag), the identifier will only contain the stripped tag, e.g. "0.11". Other styles are available. See [details.md](details.md) in the Versioneer source tree for descriptions. ## Debugging Versioneer tries to avoid fatal errors: if something goes wrong, it will tend to return a version of "0+unknown". To investigate the problem, run `setup.py version`, which will run the version-lookup code in a verbose mode, and will display the full contents of `get_versions()` (including the `error` string, which may help identify what went wrong). ## Known Limitations Some situations are known to cause problems for Versioneer. This details the most significant ones. More can be found on Github [issues page](https://github.com/warner/python-versioneer/issues). ### Subprojects Versioneer has limited support for source trees in which `setup.py` is not in the root directory (e.g. `setup.py` and `.git/` are *not* siblings). The are two common reasons why `setup.py` might not be in the root: * Source trees which contain multiple subprojects, such as [Buildbot](https://github.com/buildbot/buildbot), which contains both "master" and "slave" subprojects, each with their own `setup.py`, `setup.cfg`, and `tox.ini`. Projects like these produce multiple PyPI distributions (and upload multiple independently-installable tarballs). * Source trees whose main purpose is to contain a C library, but which also provide bindings to Python (and perhaps other langauges) in subdirectories. Versioneer will look for `.git` in parent directories, and most operations should get the right version string. However `pip` and `setuptools` have bugs and implementation details which frequently cause `pip install .` from a subproject directory to fail to find a correct version string (so it usually defaults to `0+unknown`). `pip install --editable .` should work correctly. `setup.py install` might work too. Pip-8.1.1 is known to have this problem, but hopefully it will get fixed in some later version. [Bug #38](https://github.com/warner/python-versioneer/issues/38) is tracking this issue. The discussion in [PR #61](https://github.com/warner/python-versioneer/pull/61) describes the issue from the Versioneer side in more detail. [pip PR#3176](https://github.com/pypa/pip/pull/3176) and [pip PR#3615](https://github.com/pypa/pip/pull/3615) contain work to improve pip to let Versioneer work correctly. Versioneer-0.16 and earlier only looked for a `.git` directory next to the `setup.cfg`, so subprojects were completely unsupported with those releases. ### Editable installs with setuptools <= 18.5 `setup.py develop` and `pip install --editable .` allow you to install a project into a virtualenv once, then continue editing the source code (and test) without re-installing after every change. "Entry-point scripts" (`setup(entry_points={"console_scripts": ..})`) are a convenient way to specify executable scripts that should be installed along with the python package. These both work as expected when using modern setuptools. When using setuptools-18.5 or earlier, however, certain operations will cause `pkg_resources.DistributionNotFound` errors when running the entrypoint script, which must be resolved by re-installing the package. This happens when the install happens with one version, then the egg_info data is regenerated while a different version is checked out. Many setup.py commands cause egg_info to be rebuilt (including `sdist`, `wheel`, and installing into a different virtualenv), so this can be surprising. [Bug #83](https://github.com/warner/python-versioneer/issues/83) describes this one, but upgrading to a newer version of setuptools should probably resolve it. ### Unicode version strings While Versioneer works (and is continually tested) with both Python 2 and Python 3, it is not entirely consistent with bytes-vs-unicode distinctions. Newer releases probably generate unicode version strings on py2. It's not clear that this is wrong, but it may be surprising for applications when then write these strings to a network connection or include them in bytes-oriented APIs like cryptographic checksums. [Bug #71](https://github.com/warner/python-versioneer/issues/71) investigates this question. ## Updating Versioneer To upgrade your project to a new release of Versioneer, do the following: * install the new Versioneer (`pip install -U versioneer` or equivalent) * edit `setup.cfg`, if necessary, to include any new configuration settings indicated by the release notes. See [UPGRADING](./UPGRADING.md) for details. * re-run `versioneer install` in your source tree, to replace `SRC/_version.py` * commit any changed files ## Future Directions This tool is designed to make it easily extended to other version-control systems: all VCS-specific components are in separate directories like src/git/ . The top-level `versioneer.py` script is assembled from these components by running make-versioneer.py . In the future, make-versioneer.py will take a VCS name as an argument, and will construct a version of `versioneer.py` that is specific to the given VCS. It might also take the configuration arguments that are currently provided manually during installation by editing setup.py . Alternatively, it might go the other direction and include code from all supported VCS systems, reducing the number of intermediate scripts. ## License To make Versioneer easier to embed, all its code is dedicated to the public domain. The `_version.py` that it creates is also in the public domain. Specifically, both are released under the Creative Commons "Public Domain Dedication" license (CC0-1.0), as described in https://creativecommons.org/publicdomain/zero/1.0/ . """ from __future__ import print_function try: import configparser except ImportError: import ConfigParser as configparser import errno import json import os import re import subprocess import sys class VersioneerConfig: """Container for Versioneer configuration parameters.""" def get_root(): """Get the project root directory. We require that all commands are run from the project root, i.e. the directory that contains setup.py, setup.cfg, and versioneer.py . """ root = os.path.realpath(os.path.abspath(os.getcwd())) setup_py = os.path.join(root, "setup.py") versioneer_py = os.path.join(root, "versioneer.py") if not (os.path.exists(setup_py) or os.path.exists(versioneer_py)): # allow 'python path/to/setup.py COMMAND' root = os.path.dirname(os.path.realpath(os.path.abspath(sys.argv[0]))) setup_py = os.path.join(root, "setup.py") versioneer_py = os.path.join(root, "versioneer.py") if not (os.path.exists(setup_py) or os.path.exists(versioneer_py)): err = ("Versioneer was unable to run the project root directory. " "Versioneer requires setup.py to be executed from " "its immediate directory (like 'python setup.py COMMAND'), " "or in a way that lets it use sys.argv[0] to find the root " "(like 'python path/to/setup.py COMMAND').") raise VersioneerBadRootError(err) try: # Certain runtime workflows (setup.py install/develop in a setuptools # tree) execute all dependencies in a single python process, so # "versioneer" may be imported multiple times, and python's shared # module-import table will cache the first one. So we can't use # os.path.dirname(__file__), as that will find whichever # versioneer.py was first imported, even in later projects. me = os.path.realpath(os.path.abspath(__file__)) me_dir = os.path.normcase(os.path.splitext(me)[0]) vsr_dir = os.path.normcase(os.path.splitext(versioneer_py)[0]) if me_dir != vsr_dir: print("Warning: build in %s is using versioneer.py from %s" % (os.path.dirname(me), versioneer_py)) except NameError: pass return root def get_config_from_root(root): """Read the project setup.cfg file to determine Versioneer config.""" # This might raise EnvironmentError (if setup.cfg is missing), or # configparser.NoSectionError (if it lacks a [versioneer] section), or # configparser.NoOptionError (if it lacks "VCS="). See the docstring at # the top of versioneer.py for instructions on writing your setup.cfg . setup_cfg = os.path.join(root, "setup.cfg") parser = configparser.SafeConfigParser() with open(setup_cfg, "r") as f: parser.readfp(f) VCS = parser.get("versioneer", "VCS") # mandatory def get(parser, name): if parser.has_option("versioneer", name): return parser.get("versioneer", name) return None cfg = VersioneerConfig() cfg.VCS = VCS cfg.style = get(parser, "style") or "" cfg.versionfile_source = get(parser, "versionfile_source") cfg.versionfile_build = get(parser, "versionfile_build") cfg.tag_prefix = get(parser, "tag_prefix") if cfg.tag_prefix in ("''", '""'): cfg.tag_prefix = "" cfg.parentdir_prefix = get(parser, "parentdir_prefix") cfg.verbose = get(parser, "verbose") return cfg class NotThisMethod(Exception): """Exception raised if a method is not valid for the current scenario.""" # these dictionaries contain VCS-specific tools LONG_VERSION_PY = {} HANDLERS = {} def register_vcs_handler(vcs, method): # decorator """Decorator to mark a method as the handler for a particular VCS.""" def decorate(f): """Store f in HANDLERS[vcs][method].""" if vcs not in HANDLERS: HANDLERS[vcs] = {} HANDLERS[vcs][method] = f return f return decorate def run_command(commands, args, cwd=None, verbose=False, hide_stderr=False, env=None): """Call the given command(s).""" assert isinstance(commands, list) p = None for c in commands: try: dispcmd = str([c] + args) # remember shell=False, so use git.cmd on windows, not just git p = subprocess.Popen([c] + args, cwd=cwd, env=env, stdout=subprocess.PIPE, stderr=(subprocess.PIPE if hide_stderr else None)) break except EnvironmentError: e = sys.exc_info()[1] if e.errno == errno.ENOENT: continue if verbose: print("unable to run %s" % dispcmd) print(e) return None, None else: if verbose: print("unable to find command, tried %s" % (commands,)) return None, None stdout = p.communicate()[0].strip() if sys.version_info[0] >= 3: stdout = stdout.decode() if p.returncode != 0: if verbose: print("unable to run %s (error)" % dispcmd) print("stdout was %s" % stdout) return None, p.returncode return stdout, p.returncode LONG_VERSION_PY['git'] = ''' # This file helps to compute a version number in source trees obtained from # git-archive tarball (such as those provided by githubs download-from-tag # feature). Distribution tarballs (built by setup.py sdist) and build # directories (produced by setup.py build) will contain a much shorter file # that just contains the computed version number. # This file is released into the public domain. Generated by # versioneer-0.18 (https://github.com/warner/python-versioneer) """Git implementation of _version.py.""" import errno import os import re import subprocess import sys def get_keywords(): """Get the keywords needed to look up the version information.""" # these strings will be replaced by git during git-archive. # setup.py/versioneer.py will grep for the variable names, so they must # each be defined on a line of their own. _version.py will just call # get_keywords(). git_refnames = "%(DOLLAR)sFormat:%%d%(DOLLAR)s" git_full = "%(DOLLAR)sFormat:%%H%(DOLLAR)s" git_date = "%(DOLLAR)sFormat:%%ci%(DOLLAR)s" keywords = {"refnames": git_refnames, "full": git_full, "date": git_date} return keywords class VersioneerConfig: """Container for Versioneer configuration parameters.""" def get_config(): """Create, populate and return the VersioneerConfig() object.""" # these strings are filled in when 'setup.py versioneer' creates # _version.py cfg = VersioneerConfig() cfg.VCS = "git" cfg.style = "%(STYLE)s" cfg.tag_prefix = "%(TAG_PREFIX)s" cfg.parentdir_prefix = "%(PARENTDIR_PREFIX)s" cfg.versionfile_source = "%(VERSIONFILE_SOURCE)s" cfg.verbose = False return cfg class NotThisMethod(Exception): """Exception raised if a method is not valid for the current scenario.""" LONG_VERSION_PY = {} HANDLERS = {} def register_vcs_handler(vcs, method): # decorator """Decorator to mark a method as the handler for a particular VCS.""" def decorate(f): """Store f in HANDLERS[vcs][method].""" if vcs not in HANDLERS: HANDLERS[vcs] = {} HANDLERS[vcs][method] = f return f return decorate def run_command(commands, args, cwd=None, verbose=False, hide_stderr=False, env=None): """Call the given command(s).""" assert isinstance(commands, list) p = None for c in commands: try: dispcmd = str([c] + args) # remember shell=False, so use git.cmd on windows, not just git p = subprocess.Popen([c] + args, cwd=cwd, env=env, stdout=subprocess.PIPE, stderr=(subprocess.PIPE if hide_stderr else None)) break except EnvironmentError: e = sys.exc_info()[1] if e.errno == errno.ENOENT: continue if verbose: print("unable to run %%s" %% dispcmd) print(e) return None, None else: if verbose: print("unable to find command, tried %%s" %% (commands,)) return None, None stdout = p.communicate()[0].strip() if sys.version_info[0] >= 3: stdout = stdout.decode() if p.returncode != 0: if verbose: print("unable to run %%s (error)" %% dispcmd) print("stdout was %%s" %% stdout) return None, p.returncode return stdout, p.returncode def versions_from_parentdir(parentdir_prefix, root, verbose): """Try to determine the version from the parent directory name. Source tarballs conventionally unpack into a directory that includes both the project name and a version string. We will also support searching up two directory levels for an appropriately named parent directory """ rootdirs = [] for i in range(3): dirname = os.path.basename(root) if dirname.startswith(parentdir_prefix): return {"version": dirname[len(parentdir_prefix):], "full-revisionid": None, "dirty": False, "error": None, "date": None} else: rootdirs.append(root) root = os.path.dirname(root) # up a level if verbose: print("Tried directories %%s but none started with prefix %%s" %% (str(rootdirs), parentdir_prefix)) raise NotThisMethod("rootdir doesn't start with parentdir_prefix") @register_vcs_handler("git", "get_keywords") def git_get_keywords(versionfile_abs): """Extract version information from the given file.""" # the code embedded in _version.py can just fetch the value of these # keywords. When used from setup.py, we don't want to import _version.py, # so we do it with a regexp instead. This function is not used from # _version.py. keywords = {} try: f = open(versionfile_abs, "r") for line in f.readlines(): if line.strip().startswith("git_refnames ="): mo = re.search(r'=\s*"(.*)"', line) if mo: keywords["refnames"] = mo.group(1) if line.strip().startswith("git_full ="): mo = re.search(r'=\s*"(.*)"', line) if mo: keywords["full"] = mo.group(1) if line.strip().startswith("git_date ="): mo = re.search(r'=\s*"(.*)"', line) if mo: keywords["date"] = mo.group(1) f.close() except EnvironmentError: pass return keywords @register_vcs_handler("git", "keywords") def git_versions_from_keywords(keywords, tag_prefix, verbose): """Get version information from git keywords.""" if not keywords: raise NotThisMethod("no keywords at all, weird") date = keywords.get("date") if date is not None: # git-2.2.0 added "%%cI", which expands to an ISO-8601 -compliant # datestamp. However we prefer "%%ci" (which expands to an "ISO-8601 # -like" string, which we must then edit to make compliant), because # it's been around since git-1.5.3, and it's too difficult to # discover which version we're using, or to work around using an # older one. date = date.strip().replace(" ", "T", 1).replace(" ", "", 1) refnames = keywords["refnames"].strip() if refnames.startswith("$Format"): if verbose: print("keywords are unexpanded, not using") raise NotThisMethod("unexpanded keywords, not a git-archive tarball") refs = set([r.strip() for r in refnames.strip("()").split(",")]) # starting in git-1.8.3, tags are listed as "tag: foo-1.0" instead of # just "foo-1.0". If we see a "tag: " prefix, prefer those. TAG = "tag: " tags = set([r[len(TAG):] for r in refs if r.startswith(TAG)]) if not tags: # Either we're using git < 1.8.3, or there really are no tags. We use # a heuristic: assume all version tags have a digit. The old git %%d # expansion behaves like git log --decorate=short and strips out the # refs/heads/ and refs/tags/ prefixes that would let us distinguish # between branches and tags. By ignoring refnames without digits, we # filter out many common branch names like "release" and # "stabilization", as well as "HEAD" and "master". tags = set([r for r in refs if re.search(r'\d', r)]) if verbose: print("discarding '%%s', no digits" %% ",".join(refs - tags)) if verbose: print("likely tags: %%s" %% ",".join(sorted(tags))) for ref in sorted(tags): # sorting will prefer e.g. "2.0" over "2.0rc1" if ref.startswith(tag_prefix): r = ref[len(tag_prefix):] if verbose: print("picking %%s" %% r) return {"version": r, "full-revisionid": keywords["full"].strip(), "dirty": False, "error": None, "date": date} # no suitable tags, so version is "0+unknown", but full hex is still there if verbose: print("no suitable tags, using unknown + full revision id") return {"version": "0+unknown", "full-revisionid": keywords["full"].strip(), "dirty": False, "error": "no suitable tags", "date": None} @register_vcs_handler("git", "pieces_from_vcs") def git_pieces_from_vcs(tag_prefix, root, verbose, run_command=run_command): """Get version from 'git describe' in the root of the source tree. This only gets called if the git-archive 'subst' keywords were *not* expanded, and _version.py hasn't already been rewritten with a short version string, meaning we're inside a checked out source tree. """ GITS = ["git"] if sys.platform == "win32": GITS = ["git.cmd", "git.exe"] out, rc = run_command(GITS, ["rev-parse", "--git-dir"], cwd=root, hide_stderr=True) if rc != 0: if verbose: print("Directory %%s not under git control" %% root) raise NotThisMethod("'git rev-parse --git-dir' returned error") # if there is a tag matching tag_prefix, this yields TAG-NUM-gHEX[-dirty] # if there isn't one, this yields HEX[-dirty] (no NUM) describe_out, rc = run_command(GITS, ["describe", "--tags", "--dirty", "--always", "--long", "--match", "%%s*" %% tag_prefix], cwd=root) # --long was added in git-1.5.5 if describe_out is None: raise NotThisMethod("'git describe' failed") describe_out = describe_out.strip() full_out, rc = run_command(GITS, ["rev-parse", "HEAD"], cwd=root) if full_out is None: raise NotThisMethod("'git rev-parse' failed") full_out = full_out.strip() pieces = {} pieces["long"] = full_out pieces["short"] = full_out[:7] # maybe improved later pieces["error"] = None # parse describe_out. It will be like TAG-NUM-gHEX[-dirty] or HEX[-dirty] # TAG might have hyphens. git_describe = describe_out # look for -dirty suffix dirty = git_describe.endswith("-dirty") pieces["dirty"] = dirty if dirty: git_describe = git_describe[:git_describe.rindex("-dirty")] # now we have TAG-NUM-gHEX or HEX if "-" in git_describe: # TAG-NUM-gHEX mo = re.search(r'^(.+)-(\d+)-g([0-9a-f]+)$', git_describe) if not mo: # unparseable. Maybe git-describe is misbehaving? pieces["error"] = ("unable to parse git-describe output: '%%s'" %% describe_out) return pieces # tag full_tag = mo.group(1) if not full_tag.startswith(tag_prefix): if verbose: fmt = "tag '%%s' doesn't start with prefix '%%s'" print(fmt %% (full_tag, tag_prefix)) pieces["error"] = ("tag '%%s' doesn't start with prefix '%%s'" %% (full_tag, tag_prefix)) return pieces pieces["closest-tag"] = full_tag[len(tag_prefix):] # distance: number of commits since tag pieces["distance"] = int(mo.group(2)) # commit: short hex revision ID pieces["short"] = mo.group(3) else: # HEX: no tags pieces["closest-tag"] = None count_out, rc = run_command(GITS, ["rev-list", "HEAD", "--count"], cwd=root) pieces["distance"] = int(count_out) # total number of commits # commit date: see ISO-8601 comment in git_versions_from_keywords() date = run_command(GITS, ["show", "-s", "--format=%%ci", "HEAD"], cwd=root)[0].strip() pieces["date"] = date.strip().replace(" ", "T", 1).replace(" ", "", 1) return pieces def plus_or_dot(pieces): """Return a + if we don't already have one, else return a .""" if "+" in pieces.get("closest-tag", ""): return "." return "+" def render_pep440(pieces): """Build up version string, with post-release "local version identifier". Our goal: TAG[+DISTANCE.gHEX[.dirty]] . Note that if you get a tagged build and then dirty it, you'll get TAG+0.gHEX.dirty Exceptions: 1: no tags. git_describe was just HEX. 0+untagged.DISTANCE.gHEX[.dirty] """ if pieces["closest-tag"]: rendered = pieces["closest-tag"] if pieces["distance"] or pieces["dirty"]: rendered += plus_or_dot(pieces) rendered += "%%d.g%%s" %% (pieces["distance"], pieces["short"]) if pieces["dirty"]: rendered += ".dirty" else: # exception #1 rendered = "0+untagged.%%d.g%%s" %% (pieces["distance"], pieces["short"]) if pieces["dirty"]: rendered += ".dirty" return rendered def render_pep440_pre(pieces): """TAG[.post.devDISTANCE] -- No -dirty. Exceptions: 1: no tags. 0.post.devDISTANCE """ if pieces["closest-tag"]: rendered = pieces["closest-tag"] if pieces["distance"]: rendered += ".post.dev%%d" %% pieces["distance"] else: # exception #1 rendered = "0.post.dev%%d" %% pieces["distance"] return rendered def render_pep440_post(pieces): """TAG[.postDISTANCE[.dev0]+gHEX] . The ".dev0" means dirty. Note that .dev0 sorts backwards (a dirty tree will appear "older" than the corresponding clean one), but you shouldn't be releasing software with -dirty anyways. Exceptions: 1: no tags. 0.postDISTANCE[.dev0] """ if pieces["closest-tag"]: rendered = pieces["closest-tag"] if pieces["distance"] or pieces["dirty"]: rendered += ".post%%d" %% pieces["distance"] if pieces["dirty"]: rendered += ".dev0" rendered += plus_or_dot(pieces) rendered += "g%%s" %% pieces["short"] else: # exception #1 rendered = "0.post%%d" %% pieces["distance"] if pieces["dirty"]: rendered += ".dev0" rendered += "+g%%s" %% pieces["short"] return rendered def render_pep440_old(pieces): """TAG[.postDISTANCE[.dev0]] . The ".dev0" means dirty. Eexceptions: 1: no tags. 0.postDISTANCE[.dev0] """ if pieces["closest-tag"]: rendered = pieces["closest-tag"] if pieces["distance"] or pieces["dirty"]: rendered += ".post%%d" %% pieces["distance"] if pieces["dirty"]: rendered += ".dev0" else: # exception #1 rendered = "0.post%%d" %% pieces["distance"] if pieces["dirty"]: rendered += ".dev0" return rendered def render_git_describe(pieces): """TAG[-DISTANCE-gHEX][-dirty]. Like 'git describe --tags --dirty --always'. Exceptions: 1: no tags. HEX[-dirty] (note: no 'g' prefix) """ if pieces["closest-tag"]: rendered = pieces["closest-tag"] if pieces["distance"]: rendered += "-%%d-g%%s" %% (pieces["distance"], pieces["short"]) else: # exception #1 rendered = pieces["short"] if pieces["dirty"]: rendered += "-dirty" return rendered def render_git_describe_long(pieces): """TAG-DISTANCE-gHEX[-dirty]. Like 'git describe --tags --dirty --always -long'. The distance/hash is unconditional. Exceptions: 1: no tags. HEX[-dirty] (note: no 'g' prefix) """ if pieces["closest-tag"]: rendered = pieces["closest-tag"] rendered += "-%%d-g%%s" %% (pieces["distance"], pieces["short"]) else: # exception #1 rendered = pieces["short"] if pieces["dirty"]: rendered += "-dirty" return rendered def render(pieces, style): """Render the given version pieces into the requested style.""" if pieces["error"]: return {"version": "unknown", "full-revisionid": pieces.get("long"), "dirty": None, "error": pieces["error"], "date": None} if not style or style == "default": style = "pep440" # the default if style == "pep440": rendered = render_pep440(pieces) elif style == "pep440-pre": rendered = render_pep440_pre(pieces) elif style == "pep440-post": rendered = render_pep440_post(pieces) elif style == "pep440-old": rendered = render_pep440_old(pieces) elif style == "git-describe": rendered = render_git_describe(pieces) elif style == "git-describe-long": rendered = render_git_describe_long(pieces) else: raise ValueError("unknown style '%%s'" %% style) return {"version": rendered, "full-revisionid": pieces["long"], "dirty": pieces["dirty"], "error": None, "date": pieces.get("date")} def get_versions(): """Get version information or return default if unable to do so.""" # I am in _version.py, which lives at ROOT/VERSIONFILE_SOURCE. If we have # __file__, we can work backwards from there to the root. Some # py2exe/bbfreeze/non-CPython implementations don't do __file__, in which # case we can only use expanded keywords. cfg = get_config() verbose = cfg.verbose try: return git_versions_from_keywords(get_keywords(), cfg.tag_prefix, verbose) except NotThisMethod: pass try: root = os.path.realpath(__file__) # versionfile_source is the relative path from the top of the source # tree (where the .git directory might live) to this file. Invert # this to find the root from __file__. for i in cfg.versionfile_source.split('/'): root = os.path.dirname(root) except NameError: return {"version": "0+unknown", "full-revisionid": None, "dirty": None, "error": "unable to find root of source tree", "date": None} try: pieces = git_pieces_from_vcs(cfg.tag_prefix, root, verbose) return render(pieces, cfg.style) except NotThisMethod: pass try: if cfg.parentdir_prefix: return versions_from_parentdir(cfg.parentdir_prefix, root, verbose) except NotThisMethod: pass return {"version": "0+unknown", "full-revisionid": None, "dirty": None, "error": "unable to compute version", "date": None} ''' @register_vcs_handler("git", "get_keywords") def git_get_keywords(versionfile_abs): """Extract version information from the given file.""" # the code embedded in _version.py can just fetch the value of these # keywords. When used from setup.py, we don't want to import _version.py, # so we do it with a regexp instead. This function is not used from # _version.py. keywords = {} try: f = open(versionfile_abs, "r") for line in f.readlines(): if line.strip().startswith("git_refnames ="): mo = re.search(r'=\s*"(.*)"', line) if mo: keywords["refnames"] = mo.group(1) if line.strip().startswith("git_full ="): mo = re.search(r'=\s*"(.*)"', line) if mo: keywords["full"] = mo.group(1) if line.strip().startswith("git_date ="): mo = re.search(r'=\s*"(.*)"', line) if mo: keywords["date"] = mo.group(1) f.close() except EnvironmentError: pass return keywords @register_vcs_handler("git", "keywords") def git_versions_from_keywords(keywords, tag_prefix, verbose): """Get version information from git keywords.""" if not keywords: raise NotThisMethod("no keywords at all, weird") date = keywords.get("date") if date is not None: # git-2.2.0 added "%cI", which expands to an ISO-8601 -compliant # datestamp. However we prefer "%ci" (which expands to an "ISO-8601 # -like" string, which we must then edit to make compliant), because # it's been around since git-1.5.3, and it's too difficult to # discover which version we're using, or to work around using an # older one. date = date.strip().replace(" ", "T", 1).replace(" ", "", 1) refnames = keywords["refnames"].strip() if refnames.startswith("$Format"): if verbose: print("keywords are unexpanded, not using") raise NotThisMethod("unexpanded keywords, not a git-archive tarball") refs = set([r.strip() for r in refnames.strip("()").split(",")]) # starting in git-1.8.3, tags are listed as "tag: foo-1.0" instead of # just "foo-1.0". If we see a "tag: " prefix, prefer those. TAG = "tag: " tags = set([r[len(TAG):] for r in refs if r.startswith(TAG)]) if not tags: # Either we're using git < 1.8.3, or there really are no tags. We use # a heuristic: assume all version tags have a digit. The old git %d # expansion behaves like git log --decorate=short and strips out the # refs/heads/ and refs/tags/ prefixes that would let us distinguish # between branches and tags. By ignoring refnames without digits, we # filter out many common branch names like "release" and # "stabilization", as well as "HEAD" and "master". tags = set([r for r in refs if re.search(r'\d', r)]) if verbose: print("discarding '%s', no digits" % ",".join(refs - tags)) if verbose: print("likely tags: %s" % ",".join(sorted(tags))) for ref in sorted(tags): # sorting will prefer e.g. "2.0" over "2.0rc1" if ref.startswith(tag_prefix): r = ref[len(tag_prefix):] if verbose: print("picking %s" % r) return {"version": r, "full-revisionid": keywords["full"].strip(), "dirty": False, "error": None, "date": date} # no suitable tags, so version is "0+unknown", but full hex is still there if verbose: print("no suitable tags, using unknown + full revision id") return {"version": "0+unknown", "full-revisionid": keywords["full"].strip(), "dirty": False, "error": "no suitable tags", "date": None} @register_vcs_handler("git", "pieces_from_vcs") def git_pieces_from_vcs(tag_prefix, root, verbose, run_command=run_command): """Get version from 'git describe' in the root of the source tree. This only gets called if the git-archive 'subst' keywords were *not* expanded, and _version.py hasn't already been rewritten with a short version string, meaning we're inside a checked out source tree. """ GITS = ["git"] if sys.platform == "win32": GITS = ["git.cmd", "git.exe"] out, rc = run_command(GITS, ["rev-parse", "--git-dir"], cwd=root, hide_stderr=True) if rc != 0: if verbose: print("Directory %s not under git control" % root) raise NotThisMethod("'git rev-parse --git-dir' returned error") # if there is a tag matching tag_prefix, this yields TAG-NUM-gHEX[-dirty] # if there isn't one, this yields HEX[-dirty] (no NUM) describe_out, rc = run_command(GITS, ["describe", "--tags", "--dirty", "--always", "--long", "--match", "%s*" % tag_prefix], cwd=root) # --long was added in git-1.5.5 if describe_out is None: raise NotThisMethod("'git describe' failed") describe_out = describe_out.strip() full_out, rc = run_command(GITS, ["rev-parse", "HEAD"], cwd=root) if full_out is None: raise NotThisMethod("'git rev-parse' failed") full_out = full_out.strip() pieces = {} pieces["long"] = full_out pieces["short"] = full_out[:7] # maybe improved later pieces["error"] = None # parse describe_out. It will be like TAG-NUM-gHEX[-dirty] or HEX[-dirty] # TAG might have hyphens. git_describe = describe_out # look for -dirty suffix dirty = git_describe.endswith("-dirty") pieces["dirty"] = dirty if dirty: git_describe = git_describe[:git_describe.rindex("-dirty")] # now we have TAG-NUM-gHEX or HEX if "-" in git_describe: # TAG-NUM-gHEX mo = re.search(r'^(.+)-(\d+)-g([0-9a-f]+)$', git_describe) if not mo: # unparseable. Maybe git-describe is misbehaving? pieces["error"] = ("unable to parse git-describe output: '%s'" % describe_out) return pieces # tag full_tag = mo.group(1) if not full_tag.startswith(tag_prefix): if verbose: fmt = "tag '%s' doesn't start with prefix '%s'" print(fmt % (full_tag, tag_prefix)) pieces["error"] = ("tag '%s' doesn't start with prefix '%s'" % (full_tag, tag_prefix)) return pieces pieces["closest-tag"] = full_tag[len(tag_prefix):] # distance: number of commits since tag pieces["distance"] = int(mo.group(2)) # commit: short hex revision ID pieces["short"] = mo.group(3) else: # HEX: no tags pieces["closest-tag"] = None count_out, rc = run_command(GITS, ["rev-list", "HEAD", "--count"], cwd=root) pieces["distance"] = int(count_out) # total number of commits # commit date: see ISO-8601 comment in git_versions_from_keywords() date = run_command(GITS, ["show", "-s", "--format=%ci", "HEAD"], cwd=root)[0].strip() pieces["date"] = date.strip().replace(" ", "T", 1).replace(" ", "", 1) return pieces def do_vcs_install(manifest_in, versionfile_source, ipy): """Git-specific installation logic for Versioneer. For Git, this means creating/changing .gitattributes to mark _version.py for export-subst keyword substitution. """ GITS = ["git"] if sys.platform == "win32": GITS = ["git.cmd", "git.exe"] files = [manifest_in, versionfile_source] if ipy: files.append(ipy) try: me = __file__ if me.endswith(".pyc") or me.endswith(".pyo"): me = os.path.splitext(me)[0] + ".py" versioneer_file = os.path.relpath(me) except NameError: versioneer_file = "versioneer.py" files.append(versioneer_file) present = False try: f = open(".gitattributes", "r") for line in f.readlines(): if line.strip().startswith(versionfile_source): if "export-subst" in line.strip().split()[1:]: present = True f.close() except EnvironmentError: pass if not present: f = open(".gitattributes", "a+") f.write("%s export-subst\n" % versionfile_source) f.close() files.append(".gitattributes") run_command(GITS, ["add", "--"] + files) def versions_from_parentdir(parentdir_prefix, root, verbose): """Try to determine the version from the parent directory name. Source tarballs conventionally unpack into a directory that includes both the project name and a version string. We will also support searching up two directory levels for an appropriately named parent directory """ rootdirs = [] for i in range(3): dirname = os.path.basename(root) if dirname.startswith(parentdir_prefix): return {"version": dirname[len(parentdir_prefix):], "full-revisionid": None, "dirty": False, "error": None, "date": None} else: rootdirs.append(root) root = os.path.dirname(root) # up a level if verbose: print("Tried directories %s but none started with prefix %s" % (str(rootdirs), parentdir_prefix)) raise NotThisMethod("rootdir doesn't start with parentdir_prefix") SHORT_VERSION_PY = """ # This file was generated by 'versioneer.py' (0.18) from # revision-control system data, or from the parent directory name of an # unpacked source archive. Distribution tarballs contain a pre-generated copy # of this file. import json version_json = ''' %s ''' # END VERSION_JSON def get_versions(): return json.loads(version_json) """ def versions_from_file(filename): """Try to determine the version from _version.py if present.""" try: with open(filename) as f: contents = f.read() except EnvironmentError: raise NotThisMethod("unable to read _version.py") mo = re.search(r"version_json = '''\n(.*)''' # END VERSION_JSON", contents, re.M | re.S) if not mo: mo = re.search(r"version_json = '''\r\n(.*)''' # END VERSION_JSON", contents, re.M | re.S) if not mo: raise NotThisMethod("no version_json in _version.py") return json.loads(mo.group(1)) def write_to_version_file(filename, versions): """Write the given version number to the given _version.py file.""" os.unlink(filename) contents = json.dumps(versions, sort_keys=True, indent=1, separators=(",", ": ")) with open(filename, "w") as f: f.write(SHORT_VERSION_PY % contents) print("set %s to '%s'" % (filename, versions["version"])) def plus_or_dot(pieces): """Return a + if we don't already have one, else return a .""" if "+" in pieces.get("closest-tag", ""): return "." return "+" def render_pep440(pieces): """Build up version string, with post-release "local version identifier". Our goal: TAG[+DISTANCE.gHEX[.dirty]] . Note that if you get a tagged build and then dirty it, you'll get TAG+0.gHEX.dirty Exceptions: 1: no tags. git_describe was just HEX. 0+untagged.DISTANCE.gHEX[.dirty] """ if pieces["closest-tag"]: rendered = pieces["closest-tag"] if pieces["distance"] or pieces["dirty"]: rendered += plus_or_dot(pieces) rendered += "%d.g%s" % (pieces["distance"], pieces["short"]) if pieces["dirty"]: rendered += ".dirty" else: # exception #1 rendered = "0+untagged.%d.g%s" % (pieces["distance"], pieces["short"]) if pieces["dirty"]: rendered += ".dirty" return rendered def render_pep440_pre(pieces): """TAG[.post.devDISTANCE] -- No -dirty. Exceptions: 1: no tags. 0.post.devDISTANCE """ if pieces["closest-tag"]: rendered = pieces["closest-tag"] if pieces["distance"]: rendered += ".post.dev%d" % pieces["distance"] else: # exception #1 rendered = "0.post.dev%d" % pieces["distance"] return rendered def render_pep440_post(pieces): """TAG[.postDISTANCE[.dev0]+gHEX] . The ".dev0" means dirty. Note that .dev0 sorts backwards (a dirty tree will appear "older" than the corresponding clean one), but you shouldn't be releasing software with -dirty anyways. Exceptions: 1: no tags. 0.postDISTANCE[.dev0] """ if pieces["closest-tag"]: rendered = pieces["closest-tag"] if pieces["distance"] or pieces["dirty"]: rendered += ".post%d" % pieces["distance"] if pieces["dirty"]: rendered += ".dev0" rendered += plus_or_dot(pieces) rendered += "g%s" % pieces["short"] else: # exception #1 rendered = "0.post%d" % pieces["distance"] if pieces["dirty"]: rendered += ".dev0" rendered += "+g%s" % pieces["short"] return rendered def render_pep440_old(pieces): """TAG[.postDISTANCE[.dev0]] . The ".dev0" means dirty. Eexceptions: 1: no tags. 0.postDISTANCE[.dev0] """ if pieces["closest-tag"]: rendered = pieces["closest-tag"] if pieces["distance"] or pieces["dirty"]: rendered += ".post%d" % pieces["distance"] if pieces["dirty"]: rendered += ".dev0" else: # exception #1 rendered = "0.post%d" % pieces["distance"] if pieces["dirty"]: rendered += ".dev0" return rendered def render_git_describe(pieces): """TAG[-DISTANCE-gHEX][-dirty]. Like 'git describe --tags --dirty --always'. Exceptions: 1: no tags. HEX[-dirty] (note: no 'g' prefix) """ if pieces["closest-tag"]: rendered = pieces["closest-tag"] if pieces["distance"]: rendered += "-%d-g%s" % (pieces["distance"], pieces["short"]) else: # exception #1 rendered = pieces["short"] if pieces["dirty"]: rendered += "-dirty" return rendered def render_git_describe_long(pieces): """TAG-DISTANCE-gHEX[-dirty]. Like 'git describe --tags --dirty --always -long'. The distance/hash is unconditional. Exceptions: 1: no tags. HEX[-dirty] (note: no 'g' prefix) """ if pieces["closest-tag"]: rendered = pieces["closest-tag"] rendered += "-%d-g%s" % (pieces["distance"], pieces["short"]) else: # exception #1 rendered = pieces["short"] if pieces["dirty"]: rendered += "-dirty" return rendered def render(pieces, style): """Render the given version pieces into the requested style.""" if pieces["error"]: return {"version": "unknown", "full-revisionid": pieces.get("long"), "dirty": None, "error": pieces["error"], "date": None} if not style or style == "default": style = "pep440" # the default if style == "pep440": rendered = render_pep440(pieces) elif style == "pep440-pre": rendered = render_pep440_pre(pieces) elif style == "pep440-post": rendered = render_pep440_post(pieces) elif style == "pep440-old": rendered = render_pep440_old(pieces) elif style == "git-describe": rendered = render_git_describe(pieces) elif style == "git-describe-long": rendered = render_git_describe_long(pieces) else: raise ValueError("unknown style '%s'" % style) return {"version": rendered, "full-revisionid": pieces["long"], "dirty": pieces["dirty"], "error": None, "date": pieces.get("date")} class VersioneerBadRootError(Exception): """The project root directory is unknown or missing key files.""" def get_versions(verbose=False): """Get the project version from whatever source is available. Returns dict with two keys: 'version' and 'full'. """ if "versioneer" in sys.modules: # see the discussion in cmdclass.py:get_cmdclass() del sys.modules["versioneer"] root = get_root() cfg = get_config_from_root(root) assert cfg.VCS is not None, "please set [versioneer]VCS= in setup.cfg" handlers = HANDLERS.get(cfg.VCS) assert handlers, "unrecognized VCS '%s'" % cfg.VCS verbose = verbose or cfg.verbose assert cfg.versionfile_source is not None, \ "please set versioneer.versionfile_source" assert cfg.tag_prefix is not None, "please set versioneer.tag_prefix" versionfile_abs = os.path.join(root, cfg.versionfile_source) # extract version from first of: _version.py, VCS command (e.g. 'git # describe'), parentdir. This is meant to work for developers using a # source checkout, for users of a tarball created by 'setup.py sdist', # and for users of a tarball/zipball created by 'git archive' or github's # download-from-tag feature or the equivalent in other VCSes. get_keywords_f = handlers.get("get_keywords") from_keywords_f = handlers.get("keywords") if get_keywords_f and from_keywords_f: try: keywords = get_keywords_f(versionfile_abs) ver = from_keywords_f(keywords, cfg.tag_prefix, verbose) if verbose: print("got version from expanded keyword %s" % ver) return ver except NotThisMethod: pass try: ver = versions_from_file(versionfile_abs) if verbose: print("got version from file %s %s" % (versionfile_abs, ver)) return ver except NotThisMethod: pass from_vcs_f = handlers.get("pieces_from_vcs") if from_vcs_f: try: pieces = from_vcs_f(cfg.tag_prefix, root, verbose) ver = render(pieces, cfg.style) if verbose: print("got version from VCS %s" % ver) return ver except NotThisMethod: pass try: if cfg.parentdir_prefix: ver = versions_from_parentdir(cfg.parentdir_prefix, root, verbose) if verbose: print("got version from parentdir %s" % ver) return ver except NotThisMethod: pass if verbose: print("unable to compute version") return {"version": "0+unknown", "full-revisionid": None, "dirty": None, "error": "unable to compute version", "date": None} def get_version(): """Get the short version string for this project.""" return get_versions()["version"] def get_cmdclass(): """Get the custom setuptools/distutils subclasses used by Versioneer.""" if "versioneer" in sys.modules: del sys.modules["versioneer"] # this fixes the "python setup.py develop" case (also 'install' and # 'easy_install .'), in which subdependencies of the main project are # built (using setup.py bdist_egg) in the same python process. Assume # a main project A and a dependency B, which use different versions # of Versioneer. A's setup.py imports A's Versioneer, leaving it in # sys.modules by the time B's setup.py is executed, causing B to run # with the wrong versioneer. Setuptools wraps the sub-dep builds in a # sandbox that restores sys.modules to it's pre-build state, so the # parent is protected against the child's "import versioneer". By # removing ourselves from sys.modules here, before the child build # happens, we protect the child from the parent's versioneer too. # Also see https://github.com/warner/python-versioneer/issues/52 cmds = {} # we add "version" to both distutils and setuptools from distutils.core import Command class cmd_version(Command): description = "report generated version string" user_options = [] boolean_options = [] def initialize_options(self): pass def finalize_options(self): pass def run(self): vers = get_versions(verbose=True) print("Version: %s" % vers["version"]) print(" full-revisionid: %s" % vers.get("full-revisionid")) print(" dirty: %s" % vers.get("dirty")) print(" date: %s" % vers.get("date")) if vers["error"]: print(" error: %s" % vers["error"]) cmds["version"] = cmd_version # we override "build_py" in both distutils and setuptools # # most invocation pathways end up running build_py: # distutils/build -> build_py # distutils/install -> distutils/build ->.. # setuptools/bdist_wheel -> distutils/install ->.. # setuptools/bdist_egg -> distutils/install_lib -> build_py # setuptools/install -> bdist_egg ->.. # setuptools/develop -> ? # pip install: # copies source tree to a tempdir before running egg_info/etc # if .git isn't copied too, 'git describe' will fail # then does setup.py bdist_wheel, or sometimes setup.py install # setup.py egg_info -> ? # we override different "build_py" commands for both environments if "setuptools" in sys.modules: from setuptools.command.build_py import build_py as _build_py else: from distutils.command.build_py import build_py as _build_py class cmd_build_py(_build_py): def run(self): root = get_root() cfg = get_config_from_root(root) versions = get_versions() _build_py.run(self) # now locate _version.py in the new build/ directory and replace # it with an updated value if cfg.versionfile_build: target_versionfile = os.path.join(self.build_lib, cfg.versionfile_build) print("UPDATING %s" % target_versionfile) write_to_version_file(target_versionfile, versions) cmds["build_py"] = cmd_build_py if "cx_Freeze" in sys.modules: # cx_freeze enabled? from cx_Freeze.dist import build_exe as _build_exe # nczeczulin reports that py2exe won't like the pep440-style string # as FILEVERSION, but it can be used for PRODUCTVERSION, e.g. # setup(console=[{ # "version": versioneer.get_version().split("+", 1)[0], # FILEVERSION # "product_version": versioneer.get_version(), # ... class cmd_build_exe(_build_exe): def run(self): root = get_root() cfg = get_config_from_root(root) versions = get_versions() target_versionfile = cfg.versionfile_source print("UPDATING %s" % target_versionfile) write_to_version_file(target_versionfile, versions) _build_exe.run(self) os.unlink(target_versionfile) with open(cfg.versionfile_source, "w") as f: LONG = LONG_VERSION_PY[cfg.VCS] f.write(LONG % {"DOLLAR": "$", "STYLE": cfg.style, "TAG_PREFIX": cfg.tag_prefix, "PARENTDIR_PREFIX": cfg.parentdir_prefix, "VERSIONFILE_SOURCE": cfg.versionfile_source, }) cmds["build_exe"] = cmd_build_exe del cmds["build_py"] if 'py2exe' in sys.modules: # py2exe enabled? try: from py2exe.distutils_buildexe import py2exe as _py2exe # py3 except ImportError: from py2exe.build_exe import py2exe as _py2exe # py2 class cmd_py2exe(_py2exe): def run(self): root = get_root() cfg = get_config_from_root(root) versions = get_versions() target_versionfile = cfg.versionfile_source print("UPDATING %s" % target_versionfile) write_to_version_file(target_versionfile, versions) _py2exe.run(self) os.unlink(target_versionfile) with open(cfg.versionfile_source, "w") as f: LONG = LONG_VERSION_PY[cfg.VCS] f.write(LONG % {"DOLLAR": "$", "STYLE": cfg.style, "TAG_PREFIX": cfg.tag_prefix, "PARENTDIR_PREFIX": cfg.parentdir_prefix, "VERSIONFILE_SOURCE": cfg.versionfile_source, }) cmds["py2exe"] = cmd_py2exe # we override different "sdist" commands for both environments if "setuptools" in sys.modules: from setuptools.command.sdist import sdist as _sdist else: from distutils.command.sdist import sdist as _sdist class cmd_sdist(_sdist): def run(self): versions = get_versions() self._versioneer_generated_versions = versions # unless we update this, the command will keep using the old # version self.distribution.metadata.version = versions["version"] return _sdist.run(self) def make_release_tree(self, base_dir, files): root = get_root() cfg = get_config_from_root(root) _sdist.make_release_tree(self, base_dir, files) # now locate _version.py in the new base_dir directory # (remembering that it may be a hardlink) and replace it with an # updated value target_versionfile = os.path.join(base_dir, cfg.versionfile_source) print("UPDATING %s" % target_versionfile) write_to_version_file(target_versionfile, self._versioneer_generated_versions) cmds["sdist"] = cmd_sdist return cmds CONFIG_ERROR = """ setup.cfg is missing the necessary Versioneer configuration. You need a section like: [versioneer] VCS = git style = pep440 versionfile_source = src/myproject/_version.py versionfile_build = myproject/_version.py tag_prefix = parentdir_prefix = myproject- You will also need to edit your setup.py to use the results: import versioneer setup(version=versioneer.get_version(), cmdclass=versioneer.get_cmdclass(), ...) Please read the docstring in ./versioneer.py for configuration instructions, edit setup.cfg, and re-run the installer or 'python versioneer.py setup'. """ SAMPLE_CONFIG = """ # See the docstring in versioneer.py for instructions. Note that you must # re-run 'versioneer.py setup' after changing this section, and commit the # resulting files. [versioneer] #VCS = git #style = pep440 #versionfile_source = #versionfile_build = #tag_prefix = #parentdir_prefix = """ INIT_PY_SNIPPET = """ from ._version import get_versions __version__ = get_versions()['version'] del get_versions """ def do_setup(): """Main VCS-independent setup function for installing Versioneer.""" root = get_root() try: cfg = get_config_from_root(root) except (EnvironmentError, configparser.NoSectionError, configparser.NoOptionError) as e: if isinstance(e, (EnvironmentError, configparser.NoSectionError)): print("Adding sample versioneer config to setup.cfg", file=sys.stderr) with open(os.path.join(root, "setup.cfg"), "a") as f: f.write(SAMPLE_CONFIG) print(CONFIG_ERROR, file=sys.stderr) return 1 print(" creating %s" % cfg.versionfile_source) with open(cfg.versionfile_source, "w") as f: LONG = LONG_VERSION_PY[cfg.VCS] f.write(LONG % {"DOLLAR": "$", "STYLE": cfg.style, "TAG_PREFIX": cfg.tag_prefix, "PARENTDIR_PREFIX": cfg.parentdir_prefix, "VERSIONFILE_SOURCE": cfg.versionfile_source, }) ipy = os.path.join(os.path.dirname(cfg.versionfile_source), "__init__.py") if os.path.exists(ipy): try: with open(ipy, "r") as f: old = f.read() except EnvironmentError: old = "" if INIT_PY_SNIPPET not in old: print(" appending to %s" % ipy) with open(ipy, "a") as f: f.write(INIT_PY_SNIPPET) else: print(" %s unmodified" % ipy) else: print(" %s doesn't exist, ok" % ipy) ipy = None # Make sure both the top-level "versioneer.py" and versionfile_source # (PKG/_version.py, used by runtime code) are in MANIFEST.in, so # they'll be copied into source distributions. Pip won't be able to # install the package without this. manifest_in = os.path.join(root, "MANIFEST.in") simple_includes = set() try: with open(manifest_in, "r") as f: for line in f: if line.startswith("include "): for include in line.split()[1:]: simple_includes.add(include) except EnvironmentError: pass # That doesn't cover everything MANIFEST.in can do # (http://docs.python.org/2/distutils/sourcedist.html#commands), so # it might give some false negatives. Appending redundant 'include' # lines is safe, though. if "versioneer.py" not in simple_includes: print(" appending 'versioneer.py' to MANIFEST.in") with open(manifest_in, "a") as f: f.write("include versioneer.py\n") else: print(" 'versioneer.py' already in MANIFEST.in") if cfg.versionfile_source not in simple_includes: print(" appending versionfile_source ('%s') to MANIFEST.in" % cfg.versionfile_source) with open(manifest_in, "a") as f: f.write("include %s\n" % cfg.versionfile_source) else: print(" versionfile_source already in MANIFEST.in") # Make VCS-specific changes. For git, this means creating/changing # .gitattributes to mark _version.py for export-subst keyword # substitution. do_vcs_install(manifest_in, cfg.versionfile_source, ipy) return 0 def scan_setup_py(): """Validate the contents of setup.py against Versioneer's expectations.""" found = set() setters = False errors = 0 with open("setup.py", "r") as f: for line in f.readlines(): if "import versioneer" in line: found.add("import") if "versioneer.get_cmdclass()" in line: found.add("cmdclass") if "versioneer.get_version()" in line: found.add("get_version") if "versioneer.VCS" in line: setters = True if "versioneer.versionfile_source" in line: setters = True if len(found) != 3: print("") print("Your setup.py appears to be missing some important items") print("(but I might be wrong). Please make sure it has something") print("roughly like the following:") print("") print(" import versioneer") print(" setup( version=versioneer.get_version(),") print(" cmdclass=versioneer.get_cmdclass(), ...)") print("") errors += 1 if setters: print("You should remove lines like 'versioneer.VCS = ' and") print("'versioneer.versionfile_source = ' . This configuration") print("now lives in setup.cfg, and should be removed from setup.py") print("") errors += 1 return errors if __name__ == "__main__": cmd = sys.argv[1] if cmd == "setup": errors = do_setup() errors += scan_setup_py() if errors: sys.exit(1)