├── .coveragerc ├── .github ├── dependabot.yml └── workflows │ ├── codeql.yml │ ├── publish-docs.yaml │ ├── publish.yml │ ├── test-beta.yml │ └── test.yml ├── .gitignore ├── .sonarcloud.properties ├── LICENSE ├── README.md ├── docs ├── .dates_cache.json ├── acknowledge.md ├── assets │ ├── badge │ │ └── v0.json │ ├── images │ │ ├── logo-128.png │ │ └── network_graph.png │ └── stylesheets │ │ └── extra.css ├── examples │ ├── code.md │ ├── github.md │ ├── math.md │ ├── social.md │ ├── system.md │ └── web.md ├── gen_ref_pages.py ├── index.md ├── intro.md ├── start.md ├── tutorial.md └── usage │ ├── cli.md │ └── lib.md ├── examples ├── code │ ├── git_commits.py │ ├── python_ast.py │ ├── python_dependencies.py │ ├── requirements.txt │ └── tokens.py ├── github │ ├── _client.py │ ├── commits_visibilty_graph.py │ ├── followers.graphql │ ├── followers.py │ ├── graphql.config.yml │ ├── repositories.graphql │ ├── repositories.py │ └── requirements.txt ├── math │ ├── __init__.py │ ├── _test_materializers.py │ ├── graph_atlas.py │ ├── graphs.py │ ├── gui.py │ ├── materializers.py │ ├── polygonal_graph.py │ └── requirements.txt ├── social │ ├── cache │ │ ├── 13 │ │ │ └── dd │ │ │ │ └── 73ce25face7beb30b69b64feeb77.val │ │ ├── 21 │ │ │ └── 9e │ │ │ │ └── 00846f323987ba16cfbe0127d8eb.val │ │ ├── 70 │ │ │ └── b6 │ │ │ │ └── 2aefb0269adce7fedf877fa0d267.val │ │ ├── 87 │ │ │ └── f5 │ │ │ │ └── ec1739bc369e84c3fcb302bf532a.val │ │ ├── ba │ │ │ └── fe │ │ │ │ └── 3aca7b2c38abff60e7ce5eb486a8.val │ │ ├── c7 │ │ │ └── 9e │ │ │ │ └── ce82b0288020b7152779df09bd73.val │ │ ├── cache.db │ │ ├── d2 │ │ │ └── 53 │ │ │ │ └── 3b88f2fc162561cfdbbe9abc352a.val │ │ └── e2 │ │ │ └── d5 │ │ │ └── 5d079f200eabf9b625b0473f6fbe.val │ ├── gui.py │ ├── music_artists.py │ └── requirements.txt ├── system │ ├── .ignore │ ├── files.py │ ├── processes.py │ └── requirements.txt └── web │ ├── html_dom.py │ ├── page_links.py │ └── requirements.txt ├── mkdocs.yml ├── playground ├── ethernet │ └── traceroute.py ├── genric_graph.graphql ├── graphql.config.yml ├── house_of_graphs.py ├── science │ └── caffeine.py ├── social │ ├── albums.json │ └── musicisians.py ├── text │ ├── nlp_graph.py │ └── requirements.txt └── time_series │ ├── requirements.txt │ └── visibility_graph.py ├── pyproject.toml ├── sonar-project.properties ├── src └── graphinate │ ├── __init__.py │ ├── __main__.py │ ├── builders.py │ ├── cli.py │ ├── color.py │ ├── constants.py │ ├── converters.py │ ├── modeling.py │ ├── renderers │ ├── __init__.py │ ├── graphql.py │ └── matplotlib.py │ ├── server │ ├── __init__.py │ ├── starlette │ │ ├── __init__.py │ │ └── views.py │ └── web │ │ ├── __init__.py │ │ ├── elements │ │ ├── __init__.py │ │ └── index.html │ │ ├── graphiql │ │ ├── __init__.py │ │ └── index.html │ │ ├── rapidoc │ │ ├── __init__.py │ │ └── index.html │ │ ├── static │ │ └── images │ │ │ ├── logo-128.png │ │ │ ├── logo.svg │ │ │ └── network_graph.png │ │ ├── viewer │ │ ├── __init__.py │ │ └── index.html │ │ └── voyager │ │ ├── __init__.py │ │ └── index.html │ ├── tools.py │ └── typing.py └── tests ├── conftest.py └── graphinate ├── renderers ├── test_matplotlib_draw.py └── test_matplotlib_plot.py ├── test_builders.py ├── test_cli.py ├── test_color.py ├── test_converters.py ├── test_modeling.py └── test_server.py /.coveragerc: -------------------------------------------------------------------------------- 1 | [run] 2 | branch = True 3 | relative_files = True -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | # To get started with Dependabot version updates, you'll need to specify which 2 | # package ecosystems to update and where the package manifests are located. 3 | # Please see the documentation for all configuration options: 4 | # https://docs.github.com/github/administering-a-repository/configuration-options-for-dependency-updates 5 | 6 | version: 2 7 | updates: 8 | - package-ecosystem: "pip" # See documentation for possible values 9 | directory: "/" # Location of package manifests 10 | schedule: 11 | interval: "weekly" 12 | -------------------------------------------------------------------------------- /.github/workflows/codeql.yml: -------------------------------------------------------------------------------- 1 | # For most projects, this workflow file will not need changing; you simply need 2 | # to commit it to your repository. 3 | # 4 | # You may wish to alter this file to override the set of languages analyzed, 5 | # or to provide custom queries or build logic. 6 | # 7 | # ******** NOTE ******** 8 | # We have attempted to detect the languages in your repository. Please check 9 | # the `language` matrix defined below to confirm you have the correct set of 10 | # supported CodeQL languages. 11 | # 12 | name: "CodeQL" 13 | 14 | on: 15 | push: 16 | branches: [ "main" ] 17 | pull_request: 18 | # The branches below must be a subset of the branches above 19 | branches: [ "main" ] 20 | schedule: 21 | - cron: '44 6 * * 4' 22 | 23 | jobs: 24 | analyze: 25 | name: Analyze 26 | # Runner size impacts CodeQL analysis time. To learn more, please see: 27 | # - https://gh.io/recommended-hardware-resources-for-running-codeql 28 | # - https://gh.io/supported-runners-and-hardware-resources 29 | # - https://gh.io/using-larger-runners 30 | # Consider using larger runners for possible analysis time improvements. 31 | runs-on: ${{ (matrix.language == 'swift' && 'macos-latest') || 'ubuntu-latest' }} 32 | timeout-minutes: ${{ (matrix.language == 'swift' && 120) || 360 }} 33 | permissions: 34 | actions: read 35 | contents: read 36 | security-events: write 37 | 38 | strategy: 39 | fail-fast: false 40 | matrix: 41 | language: [ 'javascript', 'python' ] 42 | # CodeQL supports [ 'cpp', 'csharp', 'go', 'java', 'javascript', 'python', 'ruby', 'swift' ] 43 | # Use only 'java' to analyze code written in Java, Kotlin or both 44 | # Use only 'javascript' to analyze code written in JavaScript, TypeScript or both 45 | # Learn more about CodeQL language support at https://aka.ms/codeql-docs/language-support 46 | 47 | steps: 48 | - name: Checkout repository 49 | uses: actions/checkout@v4 50 | 51 | # Initializes the CodeQL tools for scanning. 52 | - name: Initialize CodeQL 53 | uses: github/codeql-action/init@v3 54 | with: 55 | languages: ${{ matrix.language }} 56 | # If you wish to specify custom queries, you can do so here or in a config file. 57 | # By default, queries listed here will override any specified in a config file. 58 | # Prefix the list here with "+" to use these queries and those in the config file. 59 | 60 | # For more details on CodeQL's query packs, refer to: https://docs.github.com/en/code-security/code-scanning/automatically-scanning-your-code-for-vulnerabilities-and-errors/configuring-code-scanning#using-queries-in-ql-packs 61 | # queries: security-extended,security-and-quality 62 | 63 | 64 | # Autobuild attempts to build any compiled languages (C/C++, C#, Go, Java, or Swift). 65 | # If this step fails, then you should remove it and run the build manually (see below) 66 | - name: Autobuild 67 | uses: github/codeql-action/autobuild@v3 68 | 69 | # ℹ️ Command-line programs to run using the OS shell. 70 | # 📚 See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun 71 | 72 | # If the Autobuild fails above, remove it and uncomment the following three lines. 73 | # modify them (or add more) to build your code if your project, please refer to the EXAMPLE below for guidance. 74 | 75 | # - run: | 76 | # echo "Run, Build Application using script" 77 | # ./location_of_script_within_repo/buildscript.sh 78 | 79 | - name: Perform CodeQL Analysis 80 | uses: github/codeql-action/analyze@v3 81 | with: 82 | category: "/language:${{matrix.language}}" 83 | -------------------------------------------------------------------------------- /.github/workflows/publish-docs.yaml: -------------------------------------------------------------------------------- 1 | name: Publish Docs 2 | on: 3 | push: 4 | branches: 5 | - main 6 | permissions: 7 | contents: write 8 | jobs: 9 | deploy: 10 | runs-on: ubuntu-latest 11 | steps: 12 | - uses: actions/checkout@v4 13 | - uses: actions/setup-python@v5 14 | with: 15 | python-version: 3.x 16 | - run: echo "cache_id=$(date --utc '+%V')" >> $GITHUB_ENV 17 | - uses: actions/cache@v4 18 | with: 19 | key: mkdocs-material-${{ env.cache_id }} 20 | path: .cache 21 | restore-keys: | 22 | mkdocs-material- 23 | - run: | 24 | python -m pip install pip --upgrade 25 | pip install uv 26 | uv venv 27 | source .venv/bin/activate 28 | uv pip install mkdocs-material mkdocstrings-python mkdocs-git-committers-plugin-2 "mkdocs-git-revision-date-localized-plugin<1.4.0" mkdocs-gen-files mkdocs-glightbox mkdocs-literate-nav mkdocs-section-index 29 | mkdocs gh-deploy --force 30 | -------------------------------------------------------------------------------- /.github/workflows/publish.yml: -------------------------------------------------------------------------------- 1 | # This workflow will upload a Python Package using Twine when a release is created 2 | # For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-python#publishing-to-package-registries 3 | 4 | # This workflow uses actions that are not certified by GitHub. 5 | # They are provided by a third-party and are governed by 6 | # separate terms of service, privacy policy, and support 7 | # documentation. 8 | 9 | name: Publish 10 | on: 11 | release: 12 | types: [published] 13 | permissions: 14 | contents: read 15 | jobs: 16 | deploy: 17 | runs-on: ubuntu-latest 18 | steps: 19 | - uses: actions/checkout@v4 20 | - name: Set up Python 21 | uses: actions/setup-python@v5 22 | with: 23 | python-version: '3.x' 24 | - name: Install dependencies 25 | run: | 26 | python -m pip install --upgrade pip 27 | pip install build 28 | - name: Build package 29 | run: python -m build 30 | - name: Publish package 31 | uses: pypa/gh-action-pypi-publish@76f52bc884231f62b9a034ebfe128415bbaabdfc 32 | with: 33 | user: __token__ 34 | password: ${{ secrets.PYPI_API_TOKEN }} 35 | -------------------------------------------------------------------------------- /.github/workflows/test-beta.yml: -------------------------------------------------------------------------------- 1 | # This workflow will install Python dependencies, run tests and lint with a variety of Python versions 2 | # For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-python 3 | 4 | name: Test (Beta) 5 | 6 | on: 7 | push: 8 | branches: [ "main" ] 9 | pull_request: 10 | branches: [ "main" ] 11 | 12 | jobs: 13 | build: 14 | runs-on: ubuntu-latest 15 | strategy: 16 | fail-fast: false 17 | matrix: 18 | # https://github.com/actions/python-versions/blob/main/versions-manifest.json 19 | python-version: [ "3.14.0-alpha.7" ] 20 | steps: 21 | - uses: actions/checkout@v4 22 | - name: Set up Python ${{ matrix.python-version }} 23 | uses: actions/setup-python@v5 24 | with: 25 | python-version: ${{ matrix.python-version }} 26 | - name: Install dependencies 27 | run: | 28 | python -m pip install --upgrade pip 29 | python -m pip install --upgrade setuptools wheel 30 | python -m pip install . 31 | if [ -f requirements.txt ]; then pip install -r requirements.txt; fi 32 | - name: Lint with flake8 33 | run: | 34 | python -m pip install flake8 35 | # stop the build if there are Python syntax errors or undefined names 36 | flake8 . --count --select=E9,F63,F7,F82 --show-source --statistics 37 | # exit-zero treats all errors as warnings. The GitHub editor is 127 chars wide 38 | flake8 . --count --exit-zero --max-complexity=10 --max-line-length=127 --statistics 39 | - name: Test with pytest 40 | run: | 41 | python -m pip install faker pytest pytest-asyncio pytest-cov pytest-mock pytest-randomly pytest-xdist starlette-prometheus uvicorn[standard] 42 | pytest tests --cov=src --cov-branch --cov-report=xml --junitxml=test_results.xml -n auto -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | # This workflow will install Python dependencies, run tests and lint with a variety of Python versions 2 | # For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-python 3 | 4 | name: Test 5 | 6 | on: 7 | push: 8 | branches: [ "main" ] 9 | pull_request: 10 | branches: [ "main" ] 11 | 12 | jobs: 13 | build: 14 | runs-on: ubuntu-latest 15 | strategy: 16 | fail-fast: false 17 | matrix: 18 | python-version: [ "3.10", "3.11", "3.12", "3.13"] 19 | steps: 20 | - uses: actions/checkout@v4 21 | - name: Set up Python ${{ matrix.python-version }} 22 | uses: actions/setup-python@v5 23 | with: 24 | python-version: ${{ matrix.python-version }} 25 | - name: Install dependencies 26 | run: | 27 | python -m pip install --upgrade pip 28 | python -m pip install --upgrade setuptools wheel 29 | python -m pip install . 30 | if [ -f requirements.txt ]; then pip install -r requirements.txt; fi 31 | - name: Lint with flake8 32 | run: | 33 | python -m pip install flake8 34 | # stop the build if there are Python syntax errors or undefined names 35 | flake8 . --count --select=E9,F63,F7,F82 --show-source --statistics 36 | # exit-zero treats all errors as warnings. The GitHub editor is 127 chars wide 37 | flake8 . --count --exit-zero --max-complexity=10 --max-line-length=127 --statistics 38 | - name: Test with pytest 39 | run: | 40 | python -m pip install faker pytest pytest-asyncio pytest-cov pytest-mock pytest-randomly pytest-xdist starlette-prometheus uvicorn[standard] 41 | pytest tests --cov=src --cov-branch --cov-report=xml --junitxml=junit.xml -o junit_family=legacy -n auto 42 | - name: Upload coverage reports to Codecov 43 | uses: codecov/codecov-action@v5 44 | with: 45 | token: ${{ secrets.CODECOV_TOKEN }} 46 | - name: Upload test results to Codecov 47 | if: ${{ !cancelled() }} 48 | uses: codecov/test-results-action@v1 49 | with: 50 | token: ${{ secrets.CODECOV_TOKEN }} 51 | - name: Run codacy-coverage-reporter 52 | uses: codacy/codacy-coverage-reporter-action@v1.3.0 53 | with: 54 | #project-token: ${{ secrets.CODACY_PROJECT_TOKEN }} 55 | # or 56 | api-token: ${{ secrets.CODACY_API_TOKEN }} 57 | coverage-reports: coverage.xml 58 | # or a comma-separated list for multiple reports 59 | # coverage-reports: , -------------------------------------------------------------------------------- /.sonarcloud.properties: -------------------------------------------------------------------------------- 1 | # Path to sources 2 | #sonar.sources=. 3 | #sonar.exclusions= 4 | #sonar.inclusions= 5 | 6 | # Path to tests 7 | #sonar.tests= 8 | #sonar.test.exclusions= 9 | #sonar.test.inclusions= 10 | 11 | # Source encoding 12 | #sonar.sourceEncoding=UTF-8 13 | 14 | # Exclusions for copy-paste detection 15 | #sonar.cpd.exclusions= -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | GNU LESSER GENERAL PUBLIC LICENSE 2 | Version 3, 29 June 2007 3 | 4 | Copyright (C) 2007 Free Software Foundation, Inc. 5 | Everyone is permitted to copy and distribute verbatim copies 6 | of this license document, but changing it is not allowed. 7 | 8 | 9 | This version of the GNU Lesser General Public License incorporates 10 | the terms and conditions of version 3 of the GNU General Public 11 | License, supplemented by the additional permissions listed below. 12 | 13 | 0. Additional Definitions. 14 | 15 | As used herein, "this License" refers to version 3 of the GNU Lesser 16 | General Public License, and the "GNU GPL" refers to version 3 of the GNU 17 | General Public License. 18 | 19 | "The Library" refers to a covered work governed by this License, 20 | other than an Application or a Combined Work as defined below. 21 | 22 | An "Application" is any work that makes use of an interface provided 23 | by the Library, but which is not otherwise based on the Library. 24 | Defining a subclass of a class defined by the Library is deemed a mode 25 | of using an interface provided by the Library. 26 | 27 | A "Combined Work" is a work produced by combining or linking an 28 | Application with the Library. The particular version of the Library 29 | with which the Combined Work was made is also called the "Linked 30 | Version". 31 | 32 | The "Minimal Corresponding Source" for a Combined Work means the 33 | Corresponding Source for the Combined Work, excluding any source code 34 | for portions of the Combined Work that, considered in isolation, are 35 | based on the Application, and not on the Linked Version. 36 | 37 | The "Corresponding Application Code" for a Combined Work means the 38 | object code and/or source code for the Application, including any data 39 | and utility programs needed for reproducing the Combined Work from the 40 | Application, but excluding the System Libraries of the Combined Work. 41 | 42 | 1. Exception to Section 3 of the GNU GPL. 43 | 44 | You may convey a covered work under sections 3 and 4 of this License 45 | without being bound by section 3 of the GNU GPL. 46 | 47 | 2. Conveying Modified Versions. 48 | 49 | If you modify a copy of the Library, and, in your modifications, a 50 | facility refers to a function or data to be supplied by an Application 51 | that uses the facility (other than as an argument passed when the 52 | facility is invoked), then you may convey a copy of the modified 53 | version: 54 | 55 | a) under this License, provided that you make a good faith effort to 56 | ensure that, in the event an Application does not supply the 57 | function or data, the facility still operates, and performs 58 | whatever part of its purpose remains meaningful, or 59 | 60 | b) under the GNU GPL, with none of the additional permissions of 61 | this License applicable to that copy. 62 | 63 | 3. Object Code Incorporating Material from Library Header Files. 64 | 65 | The object code form of an Application may incorporate material from 66 | a header file that is part of the Library. You may convey such object 67 | code under terms of your choice, provided that, if the incorporated 68 | material is not limited to numerical parameters, data structure 69 | layouts and accessors, or small macros, inline functions and templates 70 | (ten or fewer lines in length), you do both of the following: 71 | 72 | a) Give prominent notice with each copy of the object code that the 73 | Library is used in it and that the Library and its use are 74 | covered by this License. 75 | 76 | b) Accompany the object code with a copy of the GNU GPL and this license 77 | document. 78 | 79 | 4. Combined Works. 80 | 81 | You may convey a Combined Work under terms of your choice that, 82 | taken together, effectively do not restrict modification of the 83 | portions of the Library contained in the Combined Work and reverse 84 | engineering for debugging such modifications, if you also do each of 85 | the following: 86 | 87 | a) Give prominent notice with each copy of the Combined Work that 88 | the Library is used in it and that the Library and its use are 89 | covered by this License. 90 | 91 | b) Accompany the Combined Work with a copy of the GNU GPL and this license 92 | document. 93 | 94 | c) For a Combined Work that displays copyright notices during 95 | execution, include the copyright notice for the Library among 96 | these notices, as well as a reference directing the user to the 97 | copies of the GNU GPL and this license document. 98 | 99 | d) Do one of the following: 100 | 101 | 0) Convey the Minimal Corresponding Source under the terms of this 102 | License, and the Corresponding Application Code in a form 103 | suitable for, and under terms that permit, the user to 104 | recombine or relink the Application with a modified version of 105 | the Linked Version to produce a modified Combined Work, in the 106 | manner specified by section 6 of the GNU GPL for conveying 107 | Corresponding Source. 108 | 109 | 1) Use a suitable shared library mechanism for linking with the 110 | Library. A suitable mechanism is one that (a) uses at run time 111 | a copy of the Library already present on the user's computer 112 | system, and (b) will operate properly with a modified version 113 | of the Library that is interface-compatible with the Linked 114 | Version. 115 | 116 | e) Provide Installation Information, but only if you would otherwise 117 | be required to provide such information under section 6 of the 118 | GNU GPL, and only to the extent that such information is 119 | necessary to install and execute a modified version of the 120 | Combined Work produced by recombining or relinking the 121 | Application with a modified version of the Linked Version. (If 122 | you use option 4d0, the Installation Information must accompany 123 | the Minimal Corresponding Source and Corresponding Application 124 | Code. If you use option 4d1, you must provide the Installation 125 | Information in the manner specified by section 6 of the GNU GPL 126 | for conveying Corresponding Source.) 127 | 128 | 5. Combined Libraries. 129 | 130 | You may place library facilities that are a work based on the 131 | Library side by side in a single library together with other library 132 | facilities that are not Applications and are not covered by this 133 | License, and convey such a combined library under terms of your 134 | choice, if you do both of the following: 135 | 136 | a) Accompany the combined library with a copy of the same work based 137 | on the Library, uncombined with any other library facilities, 138 | conveyed under the terms of this License. 139 | 140 | b) Give prominent notice with the combined library that part of it 141 | is a work based on the Library, and explaining where to find the 142 | accompanying uncombined form of the same work. 143 | 144 | 6. Revised Versions of the GNU Lesser General Public License. 145 | 146 | The Free Software Foundation may publish revised and/or new versions 147 | of the GNU Lesser General Public License from time to time. Such new 148 | versions will be similar in spirit to the present version, but may 149 | differ in detail to address new problems or concerns. 150 | 151 | Each version is given a distinguishing version number. If the 152 | Library as you received it specifies that a certain numbered version 153 | of the GNU Lesser General Public License "or any later version" 154 | applies to it, you have the option of following the terms and 155 | conditions either of that published version or of any later version 156 | published by the Free Software Foundation. If the Library as you 157 | received it does not specify a version number of the GNU Lesser 158 | General Public License, you may choose any version of the GNU Lesser 159 | General Public License ever published by the Free Software Foundation. 160 | 161 | If the Library as you received it specifies that a proxy can decide 162 | whether future versions of the GNU Lesser General Public License shall 163 | apply, that proxy's public statement of acceptance of any version is 164 | permanent authorization for you to choose that version for the 165 | Library. 166 | -------------------------------------------------------------------------------- /docs/.dates_cache.json: -------------------------------------------------------------------------------- 1 | { 2 | "acknowledge.md": { 3 | "created": "2023-08-20T00:21:55.621436", 4 | "modified": "2025-03-12T00:39:57.859235" 5 | }, 6 | "index.md": { 7 | "created": "2023-08-15T00:05:21.586874", 8 | "modified": "2025-03-12T00:40:46.459147" 9 | }, 10 | "intro.md": { 11 | "created": "2023-08-19T23:54:18.668094", 12 | "modified": "2025-03-11T23:24:02.836144" 13 | }, 14 | "start.md": { 15 | "created": "2023-08-20T00:07:42.683716", 16 | "modified": "2025-03-11T23:45:05.567864" 17 | }, 18 | "tutorial.md": { 19 | "created": "2025-02-26T00:03:30.194156", 20 | "modified": "2025-03-12T00:12:38.290310" 21 | }, 22 | "examples\\code.md": { 23 | "created": "2023-08-20T00:09:32.677003", 24 | "modified": "2025-03-07T10:14:05.629365" 25 | }, 26 | "examples\\github.md": { 27 | "created": "2023-10-05T11:43:15.316025", 28 | "modified": "2025-01-28T23:47:41.840220" 29 | }, 30 | "examples\\math.md": { 31 | "created": "2023-10-05T11:43:22.861997", 32 | "modified": "2025-01-28T23:54:42.968373" 33 | }, 34 | "examples\\social.md": { 35 | "created": "2025-02-24T23:49:35.972953", 36 | "modified": "2025-03-11T23:47:36.266891" 37 | }, 38 | "examples\\system.md": { 39 | "created": "2024-03-27T01:28:38.922865", 40 | "modified": "2024-03-27T01:51:20.126046" 41 | }, 42 | "examples\\web.md": { 43 | "created": "2023-10-05T11:43:30.550657", 44 | "modified": "2025-03-12T00:42:43.742512" 45 | }, 46 | "reference\\SUMMARY.md": { 47 | "created": "2025-03-08T20:51:52.801367", 48 | "modified": "2025-03-08T20:51:52.807370" 49 | }, 50 | "usage\\cli.md": { 51 | "created": "2023-09-27T21:23:44.140564", 52 | "modified": "2025-03-01T23:56:33.595498" 53 | }, 54 | "usage\\lib.md": { 55 | "created": "2023-09-27T21:23:44.133584", 56 | "modified": "2025-03-12T00:30:12.961397" 57 | }, 58 | "reference\\graphinate\\builders.md": { 59 | "created": "2025-03-08T20:51:52.788270", 60 | "modified": "2025-03-08T20:51:52.792269" 61 | }, 62 | "reference\\graphinate\\index.md": { 63 | "created": "2025-03-08T20:51:52.786270", 64 | "modified": "2025-03-08T20:51:52.788270" 65 | }, 66 | "reference\\graphinate\\modeling.md": { 67 | "created": "2025-03-08T20:51:52.792269", 68 | "modified": "2025-03-08T20:51:52.795269" 69 | }, 70 | "reference\\graphinate\\typing.md": { 71 | "created": "2025-03-08T20:51:52.800360", 72 | "modified": "2025-03-08T20:51:52.805368" 73 | }, 74 | "reference\\graphinate\\renderers\\graphql.md": { 75 | "created": "2025-03-08T20:51:52.796358", 76 | "modified": "2025-03-08T20:51:52.800360" 77 | }, 78 | "reference\\graphinate\\renderers\\index.md": { 79 | "created": "2025-03-08T20:51:52.794269", 80 | "modified": "2025-03-08T20:51:52.798361" 81 | }, 82 | "reference\\graphinate\\renderers\\matplotlib.md": { 83 | "created": "2025-03-08T20:51:52.798361", 84 | "modified": "2025-03-08T20:51:52.803367" 85 | } 86 | } -------------------------------------------------------------------------------- /docs/acknowledge.md: -------------------------------------------------------------------------------- 1 | # Acknowledgements 2 | 3 | ## Dependencies 4 | 5 | ### Python 6 | 7 | Click Logo. 8 | Loguru Logo. 9 | matplotlib Logo. 10 | NetworkX Logo. 11 | Strawberry GraphQL Logo. 12 | 13 | ### Javascript and HTML 14 | 15 | 3D Force-Directed Graph Logo. 16 | Graphql Voyager Logo. 17 | Tweakpane Logo 18 | 19 | ## Dev Tools 20 | 21 | uv logo. 22 | Ruff logo. 23 | Material for MkDocs 24 | pytest logo. 25 | Hatch logo. 26 | 27 | 28 | 29 | ## IDE 30 | 31 | PyCharm logo. 32 | -------------------------------------------------------------------------------- /docs/assets/badge/v0.json: -------------------------------------------------------------------------------- 1 | { 2 | "label": "", 3 | "message": "Graphinate", 4 | "logo": "https://github.com/erivlis/graphinate/assets/9897520/dae41f9f-69e5-4eb5-a488-87ce7f51fa32", 5 | "logoWidth": 10, 6 | "color": "darkviolet" 7 | } 8 | -------------------------------------------------------------------------------- /docs/assets/images/logo-128.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/erivlis/graphinate/f96201253aa713e78342f075ef990094e70bd698/docs/assets/images/logo-128.png -------------------------------------------------------------------------------- /docs/assets/images/network_graph.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/erivlis/graphinate/f96201253aa713e78342f075ef990094e70bd698/docs/assets/images/network_graph.png -------------------------------------------------------------------------------- /docs/assets/stylesheets/extra.css: -------------------------------------------------------------------------------- 1 | /* extra.css */ 2 | 3 | /* 4 | :root { 5 | --md-primary-fg-color: #E3963E; 6 | --md-primary-fg-color--dark: #FFBF00; 7 | --md-primary-fg-color--light: #EBA937; 8 | } 9 | */ 10 | 11 | :root { 12 | --md-primary-fg-color: rebeccapurple; 13 | --md-primary-fg-color--dark: midnight; 14 | --md-primary-fg-color--light: silver; 15 | } 16 | 17 | 18 | /* Title levels */ 19 | h1 { 20 | color: royalblue; 21 | } 22 | 23 | h2 { 24 | color: slateblue; 25 | } 26 | 27 | h3 { 28 | color: mediumslateblue; 29 | } 30 | 31 | h4, h5, h6 { 32 | color: steelblue; 33 | } 34 | 35 | /* Blockquotes */ 36 | /*blockquote {*/ 37 | /* border-left: 4px solid #bdc3c7; !* Light gray *!*/ 38 | /* color: #7f8c8d; !* Gray *!*/ 39 | /* !*background-color: #ecf0f1; !* Very light gray *!*!*/ 40 | /* padding: 10px;*/ 41 | /*}*/ 42 | 43 | /* Code blocks */ 44 | /*pre, code {*/ 45 | /* background-color: #f8f9fa; !* Very light gray *!*/ 46 | /* color: #2c3e50; !* Dark blue *!*/ 47 | /*}*/ 48 | 49 | /* Tables */ 50 | /*table {*/ 51 | /* border-collapse: collapse;*/ 52 | /* width: 100%;*/ 53 | /*}*/ 54 | 55 | /*th, td {*/ 56 | /* border: 1px solid #bdc3c7; !* Light gray *!*/ 57 | /* padding: 8px;*/ 58 | /* text-align: left;*/ 59 | /*}*/ 60 | 61 | /*th {*/ 62 | /* background-color: #ecf0f1; !* Very light gray *!*/ 63 | /* color: #2c3e50; !* Dark blue *!*/ 64 | /*}*/ -------------------------------------------------------------------------------- /docs/examples/code.md: -------------------------------------------------------------------------------- 1 | # Code 2 | 3 | ## GIT Commits 4 | 5 | === "GIT Commits" 6 | 7 | ``` python title="examples/code/git_commits.py" linenums="1" 8 | --8<-- "examples/code/git_commits.py" 9 | ``` 10 | 11 | --- 12 | 13 | ## Python AST 14 | 15 | === "Python AST" 16 | 17 | ```python title="examples/code/python_ast.py" linenums="1" 18 | --8<-- "examples/code/python_ast.py" 19 | ``` 20 | 21 | === "Plot" 22 | 23 | ![d3_graph_ast](https://github.com/erivlis/graphinate/assets/9897520/9e7e1ed2-3a5c-41fe-8c5f-999da4b741ff) 24 | 25 | === "3D Force-Directed Animation" 26 | 27 | 30 | 31 | --- 32 | 33 | ## Python Dependencies 34 | 35 | === "Python Dependencies" 36 | 37 | ```python title="examples/code/python_dependencies.py" linenums="1" 38 | --8<-- "examples/code/python_dependencies.py" 39 | ``` 40 | 41 | -------------------------------------------------------------------------------- /docs/examples/github.md: -------------------------------------------------------------------------------- 1 | # GitHub 2 | 3 | ## Repositories 4 | 5 | === "Repositories" 6 | ``` python title="examples/github/repositories.py" linenums="1" 7 | --8<-- "examples/github/repositories.py" 8 | ``` 9 | 10 | === "Dependencies" 11 | ``` text title="examples/github/requirements.txt" linenums="1" 12 | --8<-- "examples/github/requirements.txt" 13 | ``` 14 | 15 | ``` python title="examples/github/_client.py" linenums="1" 16 | --8<-- "examples/github/_client.py" 17 | ``` 18 | 19 | === "Plot" 20 | ![repo_graph](https://github.com/erivlis/graphinate/assets/9897520/9c044bbe-1f21-41b8-b879-95b8362ad48d) 21 | 22 | 23 | ## Followers 24 | 25 | === "Followers" 26 | ``` python title="examples/github/followers.py" linenums="1" 27 | --8<-- "examples/github/followers.py" 28 | ``` 29 | 30 | === "Dependencies" 31 | ``` text title="examples/github/requirements.txt" linenums="1" 32 | --8<-- "examples/github/requirements.txt" 33 | ``` 34 | 35 | ``` python title="examples/github/_client.py" linenums="1" 36 | --8<-- "examples/github/_client.py" 37 | ``` 38 | -------------------------------------------------------------------------------- /docs/examples/math.md: -------------------------------------------------------------------------------- 1 | # Math 2 | 3 | ## Graph Atlas 4 | 5 | === "Graph Atlas" 6 | ``` python title="examples/math/graph_atlas.py" linenums="1" 7 | --8<-- "examples/math/graph_atlas.py" 8 | ``` 9 | 10 | === "Dependencies" 11 | ``` text title="examples/math/requirements.txt" linenums="1" 12 | --8<-- "examples/math/requirements.txt" 13 | ``` 14 | 15 | ``` python title="examples/math/graph.py" linenums="1" 16 | --8<-- "examples/math/graphs.py" 17 | ``` 18 | 19 | ## Polygonal Graph 20 | 21 | === "Polygonal Graph" 22 | ``` python title="examples/math/polygonal_graph.py" linenums="1" 23 | --8<-- "examples/math/polygonal_graph.py" 24 | ``` 25 | 26 | === "Dependencies" 27 | ``` text title="examples/math/requirements.txt" linenums="1" 28 | --8<-- "examples/math/requirements.txt" 29 | ``` 30 | -------------------------------------------------------------------------------- /docs/examples/social.md: -------------------------------------------------------------------------------- 1 | # Social 2 | 3 | ## Music Artists 4 | 5 | === "Music Artists" 6 | 7 | ``` python title="examples/social/music_artists.py" linenums="1" 8 | --8<-- "examples/social/music_artists.py" 9 | ``` 10 | 11 | === "Dependencies" 12 | 13 | ``` text title="examples/social/requirements.txt" linenums="1" 14 | --8<-- "examples/social/requirements.txt" 15 | ``` -------------------------------------------------------------------------------- /docs/examples/system.md: -------------------------------------------------------------------------------- 1 | # System 2 | 3 | ## Processes 4 | 5 | === "Processes" 6 | 7 | ``` python title="examples/system/processes.py" linenums="1" 8 | --8<-- "examples/system/processes.py" 9 | ``` 10 | 11 | === "Dependencies" 12 | 13 | ``` text title="examples/system/requirements.txt" linenums="1" 14 | --8<-- "examples/system/requirements.txt" 15 | ``` -------------------------------------------------------------------------------- /docs/examples/web.md: -------------------------------------------------------------------------------- 1 | # Web 2 | 3 | ## Web Page Links 4 | 5 | === "Web Page Links" 6 | 7 | ``` python title="examples/web/page_links.py" linenums="1" 8 | --8<-- "examples/web/page_links.py" 9 | ``` 10 | 11 | === "Dependencies" 12 | 13 | ``` text title="examples/web/requirements.txt" linenums="1" 14 | --8<-- "examples/web/requirements.txt" 15 | ``` 16 | 17 | === "Plot" 18 | 19 | ![Web Page Links](https://github.com/erivlis/graphinate/assets/9897520/ea5b00a2-75d1-4d0e-86af-272f20973149) 20 | 21 | --- 22 | 23 | ## HTML DOM 24 | 25 | === "HTML DOM" 26 | 27 | ``` python title="examples/web/html_dom.py" linenums="1" 28 | --8<-- "examples/web/html_dom.py" 29 | ``` 30 | 31 | === "Dependencies" 32 | 33 | ``` text title="examples/web/requirements.txt" linenums="1" 34 | --8<-- "examples/web/requirements.txt" 35 | ``` -------------------------------------------------------------------------------- /docs/gen_ref_pages.py: -------------------------------------------------------------------------------- 1 | """Generate the code reference pages and navigation.""" 2 | 3 | from pathlib import Path 4 | 5 | import mkdocs_gen_files 6 | 7 | nav = mkdocs_gen_files.Nav() 8 | 9 | ignore = ('cli.py', 'color.py', 'converters.py', 'constants.py', 'server', 'tools') 10 | 11 | for path in sorted(Path("src").rglob("*.py")): 12 | if any(v in path.as_posix() for v in ignore): 13 | print(path) 14 | continue 15 | module_path = path.relative_to("src").with_suffix("") 16 | doc_path = path.relative_to("src").with_suffix(".md") 17 | full_doc_path = Path("reference", doc_path) 18 | 19 | parts = tuple(module_path.parts) 20 | 21 | if parts[-1] == "__init__": 22 | parts = parts[:-1] 23 | doc_path = doc_path.with_name("index.md") 24 | full_doc_path = full_doc_path.with_name("index.md") 25 | elif parts[-1] == "__main__": 26 | continue 27 | 28 | nav[parts] = doc_path.as_posix() # 29 | 30 | with mkdocs_gen_files.open(full_doc_path, "w") as fd: 31 | ident = ".".join(parts) 32 | fd.write(f"::: {ident}") 33 | 34 | mkdocs_gen_files.set_edit_path(full_doc_path, Path("../") / path) # 35 | 36 | with mkdocs_gen_files.open("reference/SUMMARY.md", "w") as nav_file: # 37 | nav_file.writelines(nav.build_literate_nav()) # 38 | -------------------------------------------------------------------------------- /docs/index.md: -------------------------------------------------------------------------------- 1 | # Graphinate. Data to Graphs. 2 | 3 | Graphinate. Data to Graphs. 4 | 5 | -------------------------------------------------------------------------------- /docs/intro.md: -------------------------------------------------------------------------------- 1 | # Introduction 2 | 3 | ## Why? 4 | 5 | ### Why Graphs? 6 | 7 | A Graph is a powerful data structure, that can be used to model a wide range of problems. 8 | Graphs are used in many fields, such as computer science, mathematics, physics, biology, social sciences and more. 9 | 10 | ### Why Graphinate? 11 | 12 | Usually the creation of a Graph is a tedious and error-prone process. 13 | It requires a lot of boilerplate code to transform data into a Graph. 14 | This process can be automated and simplified. This is where **Graphinate** comes in. 15 | 16 | ## What? 17 | 18 | ### What is a Graph? 19 | 20 | !!! quote 21 | 22 | “In a mathematician's terminology, a graph is a collection of points and lines connecting some (possibly empty) subset 23 | of them. 24 | The points of a graph are most commonly known as graph *vertices*, but may also be called *nodes* or *points*. 25 | Similarly, the lines connecting the vertices of a graph are most commonly known as graph *edges*, but may also 26 | be called *arcs* or *lines*.” 27 | 28 | — [https://mathworld.wolfram.com/Graph.html](https://mathworld.wolfram.com/Graph.html) 29 | 30 | ### What is Data? 31 | 32 | !!! quote 33 | 34 | “...data is a collection of discrete or continuous values that convey information, describing the quantity, quality, 35 | fact, statistics, other basic units of meaning, or simply sequences of symbols that may be further interpreted 36 | formally.” 37 | 38 | — [https://en.wikipedia.org/wiki/Data](https://en.wikipedia.org/wiki/Data) 39 | 40 | ### What is Graphinate? 41 | 42 | **Graphinate** is a python library that helps generate and populate Graph Data Structures from Data Sources. 43 | 44 | It can help create an efficient retrieval pipeline from a given data source, while also enabling the developer to map 45 | data payloads and hierarchies to a Graph. 46 | 47 | There are several modes of building and rendering to facilitate examination of the Graph and its content. 48 | 49 | **Graphinate** uses and is built upon the excellent [**_NetworkX_**](https://networkx.org/). 50 | 51 | ## How? 52 | 53 | ### A Graph as a Data Structure 54 | 55 | A Graph can be a useful data structure. 56 | It is, perhaps, the simplest data structure, that is a "bit more" than just a simple collection of "things". 57 | As such, it can be used to model any data source that has structure. 58 | 59 | ### Graph Elements 60 | 61 | A Graph consists of two types of elements: 62 | 63 | #### Nodes 64 | 65 | A Graph Node can be any Python Hashable object. Usually it will be a primitive type such as an integer or a string, 66 | in particular when the node in itself has no specific meaning. 67 | 68 | One can also add attributes to the node to describe additional information. This information can be anything. 69 | Often attributes are used to store scalar dimensions (e.g., weight, area, width, age, etc.) 70 | or stylistic information (e.g., color, size, shape, label, etc.). 71 | 72 | Nodes are usually visualized as circles or points. 73 | 74 | #### Edges 75 | 76 | A Graph Edge is a pair of two node values. It can also have additional attributes in the same vain as a Graph Node. 77 | 78 | Edges are usually visualized as lines connecting two nodes. 79 | 80 | ### Defining a Graph 81 | 82 | One can define a Graph in two general ways: 83 | 84 | #### Edge First 85 | 86 | The most straightforward way to generate a Graph is to supply a list of edges. The simplest definition of an edge is a 87 | pair of two values. Each value represents a node (or vertex) in the graph. Attributes may be added to the edge 88 | definition to convey additional characteristics, such as weight, direction, etc. 89 | 90 | In this case, one defines the **edges explicitly** and the **nodes implicitly**. 91 | 92 | Such a graph is focused more on the _relationships_ between nodes, or the _structure_ of the graph, 93 | than on the nodes themselves. 94 | 95 | #### Node First 96 | 97 | Alternatively, one can first add nodes (vertices) to a graph without defining edges. Attributes may be added 98 | to the node definitions to convey additional characteristics. After that, edge definitions are added to generate the 99 | relationships between the nodes. 100 | 101 | In this case, **both nodes and the edges** are defined **explicitly**. 102 | 103 | Such a graph may have a focus primarily on the nodes, and then only if needed on the relationship between them. 104 | 105 | ### Graphinate 106 | 107 | Graphinate helps to generate graphs from data sources ("Hydrate" a Graph Model from a Data Source.) 108 | It supports both *Edge First* and *Node First* creation scenarios. 109 | 110 | This is achieved the following way: 111 | 112 | #### Source 113 | 114 | First, it is required to represent the data sources, as an `Iterable` of items. 115 | It will be supply the data items that will be used to create the graph edges and/or nodes. 116 | It is recommended to use a `Generator` as the items Iterable. This way, the data source can be 117 | lazily-loaded. 118 | Such an `Iterable` or `Generator` can be, anything from a simple list of dictionaries, to a complex database query. 119 | 120 | #### Model 121 | 122 | Graphinate introduces the concept of a `GraphModel`. 123 | A GraphModel embodies a set of rules which define how to use a data source item in creating a Graph element (i.e., 124 | either a node or an edges). The `GraphModel` registers the sources using `GraphModel.node` and `GraphModel.edge` 125 | decorators. These decorators define how to extract both mandatory and optional aspects of information, which then are 126 | used to generate each Graph element. 127 | 128 | #### Builders 129 | 130 | A `GraphModel` can be used to generate an actual instance of a `GraphRepresentation`. 131 | Such a `GraphRepresentation` will contain the actual Graph data structure, populated with the data items obtained from 132 | the source. 133 | Graphinate provides several `GraphBuilder` classes that can be used to build the `GraphRepresentation` from 134 | a `GraphModel`. The actual nature of the `GraphRepresentation` will depend on the `GraphBuilder` used. 135 | 136 | #### Renderers 137 | 138 | Finally, we can render a builder's output `GraphRepresentation`. The Renderers chosen depends on the actual type of the 139 | `GraphRepresentaion` and the desired rendering output format. Graphinate provides several Renderer classes that can be 140 | used for different use cases such as visualizing, querying, reporting, etc. 141 | -------------------------------------------------------------------------------- /docs/start.md: -------------------------------------------------------------------------------- 1 | # Quick Start 2 | 3 | **Graphinate** is designed to be used as a library first and foremost. 4 | In addition, it has the following interfaces for ease of use: a CLI and a GraphQL API (using [_Strawberry GraphQL_ 5 | ](https://strawberry.rocks/)). 6 | 7 | ## Install 8 | 9 | **Graphinate** is available on PyPI: 10 | 11 | ```shell 12 | pip install graphinate 13 | ``` 14 | 15 | To install with server support 16 | 17 | ```shell 18 | pip install graphinate[server] 19 | ``` 20 | 21 | **Graphinate** officially supports Python >= 3.10. 22 | 23 | ## Demo 24 | 25 | The following code snippet shows basic simple usage of **Graphinate**. 26 | It demonstrates how to wire a simple source function to a graph model, build graph representation of several types, and 27 | render them. You can check the [Tutorial](/tutorial) for an in-depth step-by-step walkthrough, and 28 | the [Examples](/examples/code) section for additional more complex use cases. 29 | 30 | ```python title="Octagonal Graph" 31 | import graphinate 32 | 33 | N: int = 8 34 | 35 | # First Define a GraphModel instance. 36 | # It will be used to hold the graph definitions 37 | graph_model: graphinate.GraphModel = graphinate.model(name="Octagonal Graph") 38 | 39 | 40 | # Register in the Graph Model the edges' supplier generator function 41 | @graph_model.edge() 42 | def edge(): 43 | for i in range(N): 44 | yield {'source': i, 'target': i + 1} 45 | yield {'source': N, 'target': 0} 46 | 47 | 48 | # Use the NetworkX Builder 49 | builder = graphinate.builders.NetworkxBuilder(graph_model) 50 | 51 | # build the NetworkX GraphRepresentation 52 | # the output in this case is a nx.Graph instance 53 | graph = builder.build() 54 | 55 | # this supplied plot method uses matplotlib to display the graph 56 | graphinate.matplotlib.plot(graph, with_edge_labels=True) 57 | 58 | # or use the Mermaid Builder 59 | builder = graphinate.builders.MermaidBuilder(graph_model) 60 | 61 | # to create a Mermaid diagram 62 | diagram: str = builder.build() 63 | 64 | # and get Markdown or single page HTML to display it 65 | mermaid_markdown: str = graphinate.mermaid.markdown(diagram) 66 | mermaid_html: str = graphinate.mermaid.html(diagram, title=graph_model.name) 67 | 68 | # or use the GraphQL Builder 69 | builder = graphinate.builders.GraphQLBuilder(graph_model) 70 | 71 | # to create a Strawberry GraphQL schema 72 | schema = builder.build() 73 | 74 | # and serve it using Uvicorn web server 75 | graphinate.graphql.server(schema) 76 | ``` -------------------------------------------------------------------------------- /docs/usage/cli.md: -------------------------------------------------------------------------------- 1 | # CLI 2 | 3 | ## Commands 4 | 5 | ``` shell 6 | Usage: python -m graphinate [OPTIONS] COMMAND [ARGS]... 7 | 8 | Options: 9 | --help Show this message and exit. 10 | 11 | Commands: 12 | save 13 | server 14 | ``` 15 | 16 | ### Save 17 | 18 | ```console 19 | Usage: python -m graphinate save [OPTIONS] 20 | 21 | Options: 22 | -m, --model MODEL A GraphModel instance reference {module- 23 | name}:{GraphModel-instance-variable-name} For example, 24 | given var `model=GraphModel()` defined in app.py file, 25 | then the reference should be app:model 26 | --help Show this message and exit. 27 | ``` 28 | 29 | ### Server 30 | 31 | !!! tip 32 | 33 | requires the `server` extra to be installed. 34 | 35 | ```shell 36 | pip install graphinate[server] 37 | ``` 38 | 39 | ```console 40 | Usage: python -m graphinate server [OPTIONS] 41 | 42 | Options: 43 | -m, --model MODEL A GraphModel instance reference {module- 44 | name}:{GraphModel-instance-variable-name} For example, 45 | given var `model=GraphModel()` defined in app.py file, 46 | then the reference should be app:model 47 | -p, --port INTEGER Port number. 48 | --help Show this message and exit. 49 | ``` -------------------------------------------------------------------------------- /docs/usage/lib.md: -------------------------------------------------------------------------------- 1 | # Library 2 | 3 | ## Top level Functions 4 | 5 | * [`model`](/reference/graphinate/#graphinate.model) - 6 | Create a [`GraphModel`](/reference/graphinate/modeling/#graphinate.modeling.GraphModel) 7 | 8 | * [`build`](/reference/graphinate/#graphinate.build) - 9 | Generate a [`GraphRepresentation`](/ref) from a [ 10 | `GraphModel`](/reference/graphinate/modeling/#graphinate.modeling.GraphModel) 11 | 12 | ## SDK 13 | 14 | ### Model 15 | 16 | * [`graphinate.GraphModel`](/reference/graphinate/modeling/#graphinate.modeling.GraphModel) 17 | 18 | The `GraphModel` Class which is used to declaratively register, Edge and/or Node data supplier functions. 19 | Using the [`GraphModel.node()`](/reference/graphinate/modeling/#graphinate.modeling.GraphModel.node) 20 | and [`GraphMode.edge()`](/reference/graphinate/modeling/#graphinate.modeling.GraphModel.edge) decorators. 21 | 22 | ### Builders 23 | 24 | * [`graphinate.builders.NetworkxBuilder`](/reference/graphinate/builders/#graphinate.builders.NetworkxBuilder) - 25 | Generates a NetworkX Graph instance. 26 | 27 | * [`graphinate.builders.D3Builder`](/reference/graphinate/builders/#graphinate.builders.D3Builder) - Generates a D3 28 | Graph instance (i.e. a Dict). 29 | 30 | * [`graphinate.builders.GraphQLBuilder`](/reference/graphinate/builders/#graphinate.builders.GraphQLBuilder) - Generates 31 | a Strawberry GraphQL Schema instance 32 | 33 | * [`graphinate.builders.MermaidBuilder`](/reference/graphinate/builders/#graphinate.builders.MermaidBuilder) - Generates 34 | a Mermaid Diagram -------------------------------------------------------------------------------- /examples/code/git_commits.py: -------------------------------------------------------------------------------- 1 | import operator 2 | from pathlib import Path 3 | from tempfile import TemporaryDirectory 4 | 5 | import git 6 | 7 | import graphinate 8 | 9 | 10 | def create_graph_model(repo: git.Repo): 11 | # Fetch all branches from the remote 12 | repo.git.fetch('--all') 13 | 14 | graph_model = graphinate.GraphModel(name='Git Repository Graph') 15 | 16 | @graph_model.node(operator.itemgetter('type'), key=operator.itemgetter('id'), label=operator.itemgetter('label')) 17 | def commit(): 18 | for b in repo.remote().refs: 19 | for c in repo.iter_commits(b): 20 | branch = b.name.replace('origin/', '') 21 | for char in '-/. ': 22 | if char in branch: 23 | branch = branch.replace(char, '_') 24 | 25 | yield {'id': c.hexsha, 26 | 'type': branch, 27 | 'branch': b.name, 28 | 'label': c.summary} 29 | for f in c.stats.files: 30 | yield {'id': f, 31 | 'type': 'file', 32 | 'branch': b.name, 33 | 'label': f} 34 | 35 | @graph_model.edge() 36 | def branch(): 37 | for b in repo.remote().refs: 38 | for c in repo.iter_commits(b): 39 | if c.parents: 40 | yield {'source': c.parents[0].hexsha, 'target': c.hexsha} 41 | for f in c.stats.files: 42 | yield {'source': c.hexsha, 'target': f} 43 | 44 | return graph_model 45 | 46 | 47 | def git_commits(repo_url: str): 48 | with TemporaryDirectory() as temp_dir: 49 | repo_path = Path(temp_dir) 50 | 51 | with git.Repo.clone_from(repo_url, repo_path) as repo: 52 | model = create_graph_model(repo) 53 | schema = graphinate.builders.GraphQLBuilder(model).build() 54 | graphinate.graphql.server(schema) 55 | 56 | 57 | if __name__ == '__main__': 58 | # git_commits(repo_url='https://github.com/google/magika.git') 59 | git_commits(repo_url='https://github.com/erivlis/mappingtools.git') 60 | -------------------------------------------------------------------------------- /examples/code/python_ast.py: -------------------------------------------------------------------------------- 1 | """ 2 | Define functions to create an abstract syntax tree (AST) graph model using the 'graphinate' library. 3 | The 'ast_graph_model' function parses the AST of a specified class and creates nodes and edges for the graph model. 4 | The nodes represent AST nodes with their type and label, while the edges represent relationships between AST nodes. 5 | """ 6 | 7 | import ast 8 | import hashlib 9 | import inspect 10 | import operator 11 | import pickle 12 | import threading 13 | import webbrowser 14 | from _ast import AST 15 | from collections.abc import Iterable 16 | from tempfile import TemporaryDirectory 17 | 18 | import graphinate 19 | 20 | 21 | def _ast_nodes(parsed_asts: Iterable[AST]): 22 | for item in parsed_asts: 23 | if not isinstance(item, ast.Load): 24 | yield item 25 | yield from _ast_nodes(ast.iter_child_nodes(item)) 26 | 27 | 28 | def _ast_edge(parsed_ast: AST): 29 | for child_ast in ast.iter_child_nodes(parsed_ast): 30 | if not isinstance(child_ast, ast.Load): 31 | edge = {'source': parsed_ast, 'target': child_ast} 32 | edge_types = ( 33 | field_name 34 | for field_name, value 35 | in ast.iter_fields(parsed_ast) 36 | if child_ast == value 37 | or (child_ast in value if isinstance(value, list) else False) 38 | ) 39 | edge_type = next(edge_types, None) 40 | if edge_type: 41 | edge['type'] = edge_type 42 | yield edge 43 | yield from _ast_edge(child_ast) 44 | 45 | 46 | def ast_graph_model(): 47 | """ 48 | Create an abstract syntax tree (AST) graph model. 49 | 50 | Returns: 51 | GraphModel: A graph model representing the AST nodes and their relationships. 52 | """ 53 | 54 | code_object = graphinate.builders.D3Builder 55 | 56 | graph_model = graphinate.model(name=f'AST Graph - {code_object.__qualname__}',) 57 | 58 | root_ast_node = ast.parse(inspect.getsource(graphinate.builders.D3Builder)) 59 | 60 | def node_type(ast_node): 61 | return ast_node.__class__.__name__ 62 | 63 | def node_label(ast_node) -> str: 64 | label = ast_node.__class__.__name__ 65 | 66 | for field_name in ('name', 'id'): 67 | if field_name in ast_node._fields: 68 | value = operator.attrgetter(field_name)(ast_node) 69 | label = f"{label}\n{field_name}: {value}" 70 | 71 | return label 72 | 73 | def key(value): 74 | # noinspection InsecureHash 75 | return hashlib.shake_128(pickle.dumps(value)).hexdigest(20) 76 | 77 | def endpoint(value, endpoint_name): 78 | return key(value[endpoint_name]) 79 | 80 | def source(value): 81 | return endpoint(value, 'source') 82 | 83 | def target(value): 84 | return endpoint(value, 'target') 85 | 86 | @graph_model.node(type_=node_type, 87 | key=key, 88 | label=node_label, 89 | unique=True) 90 | def ast_node(**kwargs): 91 | yield from _ast_nodes([root_ast_node]) 92 | 93 | @graph_model.edge(type_='edge', 94 | source=source, 95 | target=target, 96 | label=operator.itemgetter('type')) 97 | def ast_edge(**kwargs): 98 | yield from _ast_edge(root_ast_node) 99 | 100 | return graph_model 101 | 102 | 103 | def create_server(port: int, root_directory: str, open_browser: bool = True) -> threading.Thread: 104 | import http.server 105 | import socketserver 106 | 107 | url = f"http://localhost:{port}" 108 | 109 | class Handler(http.server.SimpleHTTPRequestHandler): 110 | def __init__(self, *args, **kwargs): 111 | super().__init__(*args, directory=root_directory, **kwargs) 112 | 113 | def serve(): 114 | with socketserver.TCPServer(('', port), Handler) as httpd: 115 | print("Serving at:", url) 116 | httpd.serve_forever() 117 | 118 | server_thread = threading.Thread(target=serve) 119 | server_thread.daemon = True 120 | server_thread.start() 121 | 122 | if open_browser: 123 | webbrowser.open(url) 124 | 125 | 126 | if __name__ == '__main__': 127 | ast_model = ast_graph_model() 128 | # schema = graphinate.builders.GraphQLBuilder(ast_model).build() 129 | # graphinate.graphql.server(schema) 130 | 131 | diagram = graphinate.builders.MermaidBuilder(ast_model).build(with_edge_labels=False) 132 | 133 | html_diagram = graphinate.mermaid.html(diagram) 134 | 135 | ## Save the HTML diagram to a file and serve it 136 | with TemporaryDirectory() as temp_dir: 137 | with open(f"{temp_dir}/index.html", 'w') as f: 138 | f.write(html_diagram) 139 | 140 | # Serve the HTML diagram 141 | create_server(port=8077, root_directory=temp_dir, open_browser=True) 142 | 143 | # Keep the main thread alive to allow the server to run 144 | try: 145 | while True: 146 | pass 147 | except KeyboardInterrupt: 148 | print("Server stopped") 149 | -------------------------------------------------------------------------------- /examples/code/python_dependencies.py: -------------------------------------------------------------------------------- 1 | from pipdeptree._cli import get_options 2 | from pipdeptree._discovery import get_installed_distributions 3 | from pipdeptree._models import PackageDAG 4 | 5 | import graphinate 6 | 7 | 8 | def dependency_graph_model(): 9 | """ 10 | Generate a dependency graph model. 11 | 12 | Returns: 13 | GraphModel: A graph model representing the dependency graph. 14 | """ 15 | 16 | options = get_options(args=None) 17 | 18 | pkgs = get_installed_distributions(local_only=options.local_only, user_only=options.user_only) 19 | tree = PackageDAG.from_pkgs(pkgs) 20 | 21 | graph_model = graphinate.model(name="Dependency Graph") 22 | 23 | @graph_model.edge() 24 | def dependency(): 25 | for p, d in tree.items(): 26 | for c in d: 27 | yield {'source': p.project_name, 'target': c.project_name} 28 | 29 | return graph_model 30 | 31 | 32 | if __name__ == '__main__': 33 | dependency_model = dependency_graph_model() 34 | schema = graphinate.builders.GraphQLBuilder(dependency_model).build() 35 | graphinate.graphql.server(schema) 36 | -------------------------------------------------------------------------------- /examples/code/requirements.txt: -------------------------------------------------------------------------------- 1 | GitPython 2 | graphinate 3 | pipdeptree 4 | -------------------------------------------------------------------------------- /examples/code/tokens.py: -------------------------------------------------------------------------------- 1 | import itertools 2 | import operator 3 | import sys 4 | 5 | from pygments import lex 6 | from pygments.lexers import guess_lexer_for_filename 7 | 8 | import graphinate 9 | 10 | 11 | def load_file(file_path): 12 | with open(file_path) as file: 13 | return file.read() 14 | 15 | 16 | def tokenize_file(file_path): 17 | content = load_file(file_path) 18 | lexer = guess_lexer_for_filename(file_path, content) 19 | return lex(content, lexer) 20 | 21 | 22 | def token_graph_model(file_path): 23 | graph_model = graphinate.model(name="Token Graph") 24 | 25 | def token_type(v): 26 | return str(v[0]).replace('.', '_') 27 | 28 | def token_key(v): 29 | return f"{v[0]}-{v[1]}" 30 | 31 | @graph_model.node(token_type, key=token_key) 32 | def token(): 33 | yield from tokenize_file(file_path) 34 | 35 | @graph_model.edge(source=operator.itemgetter(0), target=operator.itemgetter(1)) 36 | def edge(): 37 | yield from itertools.pairwise(token_key(t) for t in tokenize_file(file_path)) 38 | 39 | return graph_model 40 | 41 | 42 | if __name__ == '__main__': 43 | if len(sys.argv) != 2: 44 | print("Usage: python tokens.py ") 45 | sys.exit(1) 46 | 47 | file_path = sys.argv[1] 48 | token_model = token_graph_model(file_path) 49 | schema = graphinate.builders.GraphQLBuilder(token_model).build() 50 | graphinate.graphql.server(schema) 51 | -------------------------------------------------------------------------------- /examples/github/_client.py: -------------------------------------------------------------------------------- 1 | import functools 2 | import os 3 | from collections.abc import Iterable 4 | from typing import Optional, Union 5 | 6 | # see requirements.txt 7 | from github import Auth, Github 8 | from github.AuthenticatedUser import AuthenticatedUser 9 | from github.Commit import Commit 10 | from github.File import File 11 | from github.NamedUser import NamedUser 12 | from github.Repository import Repository 13 | 14 | # define a 'GITHUB_TOKEN' Env Var. 15 | token = os.getenv('GITHUB_TOKEN') 16 | 17 | # using an access token 18 | auth = Auth.Token(token) 19 | 20 | # Public Web GitHub 21 | client = Github(auth=auth) 22 | 23 | 24 | # or GitHub Enterprise with custom hostname 25 | # g = Github(auth=auth, base_url='https://{hostname}/api/v3') 26 | 27 | 28 | @functools.lru_cache 29 | def github_user(user_id: Optional[str] = None) -> Union[NamedUser, AuthenticatedUser]: 30 | """ 31 | Get the GitHub user object for the specified user ID or the authenticated user. 32 | 33 | Args: 34 | user_id (Optional[str]): The ID of the user to retrieve. 35 | If not provided, retrieve the authenticated user. 36 | 37 | Returns: 38 | Union[NamedUser, AuthenticatedUser]: The GitHub user object corresponding to the user ID provided, 39 | or the authenticated user if no user ID is specified. 40 | Note: 41 | This function requires authentication with a valid GitHub token. 42 | """ 43 | user = client.get_user(user_id) if user_id else client.get_user() 44 | return user 45 | 46 | 47 | @functools.lru_cache 48 | def github_repositories( 49 | user_id: Optional[str] = None, 50 | repo_id: Optional[str] = None) -> Iterable[Repository]: 51 | """ 52 | Get the GitHub repositories for the specified user ID or the authenticated user. 53 | 54 | Args: 55 | user_id (Optional[str]): The ID of the user whose repositories to retrieve. 56 | If not provided, retrieve repositories of the authenticated user. 57 | repo_id (Optional[str]): The ID of the repository to retrieve. 58 | If provided, only that repository will be returned. 59 | 60 | Returns: 61 | Iterable[Repository]: 62 | A list of GitHub repository objects corresponding to the user ID and/or repository ID provided. 63 | 64 | Note: 65 | This function requires authentication with a valid GitHub token. 66 | """ 67 | 68 | user = github_user(user_id) 69 | if repo_id and (repo := user.get_repo(name=repo_id)): 70 | return [repo] 71 | else: 72 | return user.get_repos() 73 | 74 | 75 | def github_commits( 76 | repo: Repository, 77 | commit_id: Optional[str] = None) -> Iterable[Commit]: 78 | """ 79 | Retrieve commits from a GitHub repository. 80 | 81 | Args: 82 | repo (Repository): The GitHub repository object from which to retrieve commits. 83 | commit_id (str, optional): The ID of the commit to retrieve. 84 | If provided, only that commit will be returned. 85 | Defaults to None. 86 | 87 | Returns: 88 | Iterable[Commit]: An Iterable of Commit objects representing the commits in the repository. 89 | 90 | Example: 91 | To retrieve all commits from a repository: 92 | ``` 93 | for commit in github_commits(repo): 94 | print(commit) 95 | ``` 96 | 97 | To retrieve a specific commit by ID: 98 | ``` 99 | for commit in github_commits(repo, commit_id='abcdef123456'): 100 | print(commit) 101 | ``` 102 | 103 | Note: 104 | This function requires authentication with a valid GitHub token. 105 | """ 106 | if commit_id and (commit := repo.get_commit(sha=commit_id)): 107 | yield commit 108 | else: 109 | yield from repo.get_commits() 110 | 111 | 112 | def github_files( 113 | commit: Commit, 114 | file_id: Optional[str] = None) -> Iterable[File]: 115 | """ 116 | Retrieves Files from a GitHub Commit 117 | 118 | Args: 119 | commit (Commit): A Commit object from the GitHub API. 120 | file_id (Optional[str]): An optional parameter specifying the filename to filter the files. Default is None. 121 | 122 | Returns: 123 | Iterable[File]: An Iterable of File objects based on the filtering criteria. 124 | 125 | Note: 126 | This function requires authentication with a valid GitHub token. 127 | """ 128 | files: list[File] = commit.files 129 | if file_id: 130 | yield from [file for file in files if file.filename == file_id] 131 | else: 132 | yield from files 133 | -------------------------------------------------------------------------------- /examples/github/commits_visibilty_graph.py: -------------------------------------------------------------------------------- 1 | """ 2 | https://www.pnas.org/doi/10.1073/pnas.0709247105 3 | """ 4 | 5 | from datetime import timedelta 6 | from typing import Optional 7 | 8 | from _client import github_commits, github_repositories, github_user 9 | from github.GitCommit import GitCommit 10 | 11 | 12 | def commit_interval(user_id: Optional[str] = None, 13 | repository_id: Optional[str] = None, 14 | commit_id: Optional[str] = None): 15 | github_user(user_id) 16 | for repo in github_repositories(user_id, repository_id): 17 | for commit in github_commits(repo, commit_id): 18 | git_commit: GitCommit = commit.commit 19 | parents = git_commit.parents 20 | parent_git_commit: GitCommit = parents[0] if parents else None 21 | author_datetime = git_commit.author.date 22 | parent_author_datetime = parent_git_commit.author.date if parent_git_commit else author_datetime 23 | interval: timedelta = author_datetime - parent_author_datetime 24 | s = interval.total_seconds() 25 | yield s 26 | 27 | 28 | series = list(commit_interval('erivlis', 'graphinate')) 29 | series.reverse() 30 | 31 | print(series) 32 | -------------------------------------------------------------------------------- /examples/github/followers.graphql: -------------------------------------------------------------------------------- 1 | query GitHubFollowers { 2 | info: graph { 3 | name 4 | types 5 | } 6 | followers: nodes { 7 | ...NodeDetail 8 | neighbors { 9 | ...NodeDetail 10 | } 11 | children { 12 | ...NodeDetail 13 | children { 14 | ...NodeDetail 15 | } 16 | } 17 | } 18 | } 19 | 20 | fragment NodeDetail on GraphNode { 21 | id 22 | label 23 | color 24 | type 25 | lineage 26 | } -------------------------------------------------------------------------------- /examples/github/followers.py: -------------------------------------------------------------------------------- 1 | """ 2 | Defines a function `followers_graph_model` that creates a graph model representing GitHub followers. 3 | It recursively fetches followers of a given user up to a specified maximum depth. 4 | The function yields edges between users in the graph. 5 | """ 6 | 7 | from typing import Optional 8 | 9 | from _client import github_user # see _client.py 10 | 11 | import graphinate 12 | 13 | DEPTH = 0 14 | 15 | 16 | def followers_graph_model(max_depth: int = DEPTH): 17 | """ 18 | Create a graph model representing GitHub followers. 19 | 20 | Args: 21 | max_depth (int): The maximum depth to fetch followers recursively (default is 0). 22 | 23 | Returns: 24 | GraphModel: A graph model representing GitHub followers. 25 | """ 26 | 27 | graph_model = graphinate.model(name='Github Followers Graph') 28 | 29 | def _followers(user_id: Optional[str] = None, depth: int = 0, **kwargs): 30 | user = github_user(user_id) 31 | for follower in user.get_followers(): 32 | yield {'source': user.login, 'target': follower.login} 33 | if depth < max_depth: 34 | yield from _followers(follower.login, depth=depth + 1, **kwargs) 35 | 36 | @graph_model.edge() 37 | def followed_by(user_id: Optional[str] = None, **kwargs): 38 | yield from _followers(user_id, **kwargs) 39 | 40 | return graph_model 41 | 42 | 43 | if __name__ == '__main__': 44 | followers_model = followers_graph_model(max_depth=1) 45 | 46 | params = { 47 | 'user_id': 'erivlis' 48 | # 'user_id': 'andybrewer' 49 | # 'user_id' "strawberry-graphql" 50 | } 51 | 52 | builder = graphinate.builders.GraphQLBuilder(followers_model, graph_type=graphinate.GraphType.DiGraph) 53 | schema = builder.build(default_node_attributes={'type': 'user'}, **params) 54 | graphinate.graphql.server(schema) 55 | -------------------------------------------------------------------------------- /examples/github/graphql.config.yml: -------------------------------------------------------------------------------- 1 | schema: http://localhost:8000/graphql 2 | -------------------------------------------------------------------------------- /examples/github/repositories.graphql: -------------------------------------------------------------------------------- 1 | query GitHubRepos { 2 | graph { 3 | name 4 | types 5 | size 6 | order 7 | averageDegree 8 | 9 | weisfeilerLehmanGraphHash 10 | } 11 | users { 12 | ...NodeDetail 13 | children { 14 | ...NodeDetail 15 | children { 16 | ...NodeDetail 17 | children { 18 | ...NodeDetail 19 | } 20 | } 21 | } 22 | } 23 | } 24 | 25 | fragment NodeDetail on GraphNode { 26 | id 27 | label 28 | color 29 | type 30 | lineage 31 | } -------------------------------------------------------------------------------- /examples/github/repositories.py: -------------------------------------------------------------------------------- 1 | import itertools 2 | import operator 3 | import pathlib 4 | from typing import Optional 5 | 6 | from _client import github_commits, github_files, github_repositories, github_user # see _client.py 7 | 8 | import graphinate 9 | 10 | 11 | def repo_graph_model(): # noqa: C901 12 | """ 13 | Create a graph model for GitHub repositories. 14 | 15 | Returns: 16 | GraphModel: A graph model representing GitHub repositories with nodes and edges. 17 | """ 18 | 19 | graph_model = graphinate.model(name='GitHub Repository Graph') 20 | 21 | @graph_model.edge() 22 | def github(user_id: Optional[str] = None, 23 | repository_id: Optional[str] = None, 24 | commit_id: Optional[str] = None, 25 | file_id: Optional[str] = None, 26 | **kwargs): 27 | user = github_user(user_id) 28 | for repo in github_repositories(user_id, repository_id): 29 | yield {'source': (user.login,), 'target': (user.login, repo.name)} 30 | for commit in github_commits(repo, commit_id): 31 | yield { 32 | 'source': (user.login, repo.name), 33 | 'target': (user.login, repo.name, commit.sha) 34 | } 35 | for file in github_files(commit, file_id): 36 | yield { 37 | 'source': (user.login, repo.name, commit.sha), 38 | 'target': (user.login, repo.name, commit.sha, file.filename) 39 | } 40 | 41 | user_node = graph_model.node(key=operator.attrgetter('login'), 42 | value=operator.attrgetter('raw_data'), 43 | label=operator.itemgetter('name')) 44 | 45 | repository_node = graph_model.node(parent_type='user', 46 | key=operator.attrgetter('name'), 47 | value=operator.attrgetter('raw_data'), 48 | label=operator.itemgetter('name')) 49 | 50 | def commit_label(commit): 51 | return commit['sha'][-7:] 52 | 53 | commit_node = graph_model.node(parent_type='repository', 54 | key=operator.attrgetter('sha'), 55 | value=operator.attrgetter('raw_data'), 56 | label=commit_label) 57 | 58 | file_node = graph_model.node(parent_type='commit', 59 | unique=True, 60 | key=operator.attrgetter('filename'), 61 | value=operator.attrgetter('raw_data'), 62 | label=operator.itemgetter('filename')) 63 | 64 | @user_node 65 | def user(user_id: Optional[str] = None, **kwargs): 66 | yield github_user(user_id) 67 | 68 | @repository_node 69 | def repository(user_id: Optional[str] = None, 70 | repository_id: Optional[str] = None, 71 | **kwargs): 72 | repos = github_repositories(user_id, repository_id) 73 | yield from repos 74 | 75 | @commit_node 76 | def commit(user_id: Optional[str] = None, 77 | repository_id: Optional[str] = None, 78 | commit_id: Optional[str] = None, 79 | **kwargs): 80 | for repo in github_repositories(user_id, repository_id): 81 | yield from github_commits(repo, commit_id) 82 | 83 | def file_type(user_id: Optional[str] = None, 84 | repository_id: Optional[str] = None, 85 | commit_id: Optional[str] = None, 86 | file_type_id: Optional[str] = None, 87 | **kwargs): 88 | def group_key(file): 89 | return pathlib.PurePath(file).suffix 90 | 91 | for repo in github_repositories(user_id, repository_id): 92 | for commit in github_commits(repo, commit_id): 93 | yield from ((k, list(g)) for k, g in 94 | itertools.groupby( 95 | sorted(github_files(commit), 96 | key=group_key), group_key 97 | )) 98 | 99 | @file_node 100 | def file(user_id: Optional[str] = None, 101 | repository_id: Optional[str] = None, 102 | commit_id: Optional[str] = None, 103 | file_id: Optional[str] = None, 104 | **kwargs): 105 | for repo in github_repositories(user_id, repository_id): 106 | for commit in github_commits(repo, commit_id): 107 | yield from github_files(commit, file_id) 108 | 109 | return graph_model 110 | 111 | 112 | if __name__ == '__main__': 113 | repo_model = repo_graph_model() 114 | 115 | params = { 116 | 'user_id': 'erivlis', 117 | 'repository_id': 'graphinate', 118 | # 'user_id': 'andybrewer', 119 | # 'repository_id': 'operation-go', 120 | # 'commit_id': None, 121 | # 'file_id': 'README.md', 122 | # 'user_id' "strawberry-graphql" 123 | } 124 | 125 | schema = graphinate.builders.GraphQLBuilder(repo_model).build(**params) 126 | graphinate.graphql.server(schema) 127 | -------------------------------------------------------------------------------- /examples/github/requirements.txt: -------------------------------------------------------------------------------- 1 | graphinate 2 | PyGithub 3 | -------------------------------------------------------------------------------- /examples/math/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/erivlis/graphinate/f96201253aa713e78342f075ef990094e70bd698/examples/math/__init__.py -------------------------------------------------------------------------------- /examples/math/_test_materializers.py: -------------------------------------------------------------------------------- 1 | import functools 2 | 3 | import pytest 4 | from matplotlib import pyplot as plt 5 | 6 | import examples.math.materializers 7 | import graphinate 8 | 9 | 10 | def test_materialize(map_graph_model, capsys): 11 | # Arrange 12 | expected_snippet = '"graph": {\n "name": "Map",' 13 | *_, graph_model = map_graph_model 14 | builder, handler = examples.math.materializers.Materializers.D3Graph.value 15 | 16 | # Act 17 | examples.math.materializers.materialize(graph_model, builder=builder, builder_output_handler=handler) 18 | captured = capsys.readouterr() 19 | 20 | # Assert 21 | assert expected_snippet in captured.out 22 | assert captured.err == "" 23 | 24 | 25 | def test_materialize_d3graph(map_graph_model, monkeypatch, capsys): 26 | # Arrange 27 | monkeypatch.setattr(plt, 'show', lambda: None) 28 | *_, graph_model = map_graph_model 29 | builder, handler = examples.math.materializers.Materializers.D3Graph.value 30 | 31 | expected_snippet = '"graph": {\n "name": "Map",' 32 | 33 | # Act 34 | examples.math.materializers.materialize(graph_model, builder=builder, builder_output_handler=handler) 35 | captured = capsys.readouterr() 36 | 37 | # Assert 38 | assert expected_snippet in captured.out 39 | assert captured.err == "" 40 | 41 | 42 | def valid_materialization(*args, **kwargs) -> bool: 43 | examples.math.materializers.materialize(*args, **kwargs) 44 | return True 45 | 46 | 47 | def test_materialize_graphql(map_graph_model, monkeypatch): 48 | with monkeypatch.context(): 49 | # Arrange 50 | import uvicorn 51 | monkeypatch.setattr(uvicorn, "run", lambda *args, **kwargs: None) 52 | *_, graph_model = map_graph_model 53 | builder, handler = examples.math.materializers.Materializers.GraphQL.value 54 | 55 | # Act & Assert 56 | assert valid_materialization(graph_model, builder=builder, builder_output_handler=handler) 57 | 58 | 59 | networkx_materializers = [ 60 | examples.math.materializers.Materializers.NetworkX.value, 61 | examples.math.materializers.Materializers.NetworkX_with_edge_labels.value, 62 | (graphinate.builders.NetworkxBuilder, 63 | functools.partial(graphinate.materializers.matplotlib.plot, with_node_labels=False)) 64 | ] 65 | 66 | 67 | @pytest.mark.parametrize('materializer', networkx_materializers) 68 | def test_materialize_networkx(map_graph_model, materializer, monkeypatch): 69 | with monkeypatch.context(): 70 | monkeypatch.setattr(plt, 'show', lambda: None) 71 | 72 | # Arrange 73 | *_, graph_model = map_graph_model 74 | builder, handler = materializer 75 | 76 | # Act & Assert 77 | assert valid_materialization(graph_model, builder=builder, builder_output_handler=handler) 78 | 79 | 80 | def test_materialize_none(map_graph_model, monkeypatch): 81 | # Arrange 82 | import uvicorn 83 | monkeypatch.setattr(uvicorn, "run", lambda *args, **kwargs: None) 84 | *_, graph_model = map_graph_model 85 | 86 | # Act & Assert 87 | with pytest.raises(ValueError, match="Missing: builder, builder_output_handler"): 88 | examples.math.materializers.materialize(graph_model, builder=None, builder_output_handler=None) 89 | -------------------------------------------------------------------------------- /examples/math/graph_atlas.py: -------------------------------------------------------------------------------- 1 | import operator 2 | 3 | import graphs 4 | import networkx as nx 5 | from materializers import Materializers, materialize 6 | 7 | import graphinate 8 | 9 | 10 | def model(items: list[tuple[str, nx.Graph]]) -> graphinate.GraphModel: 11 | """ 12 | Generate a graph model based on the provided iterable of graphs. 13 | The function creates a graph model named 'Graph Atlas' using the 'graphinate' library. 14 | It then combines all the graphs from the input iterable into a single disjoint union graph using NetworkX library. 15 | The function defines edges for the combined graph by iterating over all edges in the disjoint union graph and 16 | yielding dictionaries with 'source' and 'target' keys representing the edge connections. 17 | Finally, the function yields the created graph model containing the combined graph with defined edges. 18 | 19 | Args: 20 | items: A list containing graphs to be combined into a single graph model. 21 | 22 | Yields: 23 | GraphModel: A graph model containing the combined graph with defined edges. 24 | """ 25 | 26 | def items_iter(recs): 27 | for name, g in recs: 28 | print(name) 29 | yield g 30 | 31 | g = nx.disjoint_union_all(items_iter(items)) if len(items) > 1 else items[0][1] 32 | 33 | graph_model = graphinate.model('Graph Atlas') 34 | 35 | @graph_model.node(operator.itemgetter(1), 36 | key=operator.itemgetter(0), 37 | value=operator.itemgetter(0)) 38 | def nodes(): 39 | yield from g.nodes(data='type') 40 | 41 | @graph_model.edge(operator.itemgetter('type')) 42 | def edge(): 43 | yield from ({'source': e[0], 'target': e[1], **e[2]} for e in g.edges.data()) 44 | 45 | return graph_model 46 | 47 | 48 | if __name__ == '__main__': 49 | from gui import ListboxChooser, RadiobuttonChooser 50 | 51 | graph_atlas = graphs.atlas() 52 | 53 | listbox_chooser = ListboxChooser('Choose Graph/s', graph_atlas) 54 | choices = list(listbox_chooser.get_choices()) 55 | model = model(choices) 56 | 57 | # or 58 | # model(graph_atlas.values()) 59 | 60 | radiobutton_chooser = RadiobuttonChooser('Choose Materializer', 61 | options={m.name: m.value for m in Materializers}, 62 | default=(None, None)) 63 | result = radiobutton_chooser.get_choice() 64 | builder, handler = result[1] 65 | materialize(model, builder=builder, builder_output_handler=handler) 66 | -------------------------------------------------------------------------------- /examples/math/gui.py: -------------------------------------------------------------------------------- 1 | import platform 2 | import tkinter as tk 3 | from tkinter import ttk 4 | 5 | 6 | class ModalWindow(tk.Tk): 7 | def __init__(self, title: str): 8 | super().__init__() 9 | self.title(title) 10 | self.configure_window() 11 | 12 | def configure_window(self): 13 | if platform.system().lower() == 'windows': 14 | self.wm_attributes('-toolwindow', 'True') 15 | self.wm_attributes('-topmost', 'True') 16 | self.resizable(False, False) 17 | 18 | 19 | class RadiobuttonChooser(ModalWindow): 20 | """ 21 | Usage Example: 22 | ```python 23 | radiobutton_chooser = RadiobuttonChooser("Choose an option", {"Option 1": 1, "Option 2": 2}) 24 | choice, value = radiobutton_chooser.get_choice() 25 | print(choice, value) 26 | ``` 27 | """ 28 | 29 | def __init__(self, window_title: str, options: dict, default=None): 30 | super().__init__(window_title) 31 | self.exit_button = None 32 | self.choice_var = tk.StringVar(self, None) 33 | self.default = default 34 | self.options = options 35 | self.create_widgets() 36 | self.mainloop() 37 | 38 | def create_widgets(self): 39 | frame = ttk.Frame(self, borderwidth=5, relief='solid') 40 | frame.pack(padx=4, pady=4) 41 | 42 | ttk.Label(frame, text="Output Mode:").pack() 43 | 44 | for option in self.options: 45 | ttk.Radiobutton( 46 | frame, 47 | text=option, 48 | variable=self.choice_var, 49 | value=option, 50 | command=self.enable_exit_button, 51 | padding=4 52 | ).pack(side=tk.TOP, anchor="w") 53 | 54 | self.exit_button = ttk.Button(self, text="OK", command=self.destroy, state=tk.DISABLED) 55 | self.exit_button.pack(pady=4, side=tk.BOTTOM) 56 | 57 | def enable_exit_button(self): 58 | self.exit_button['state'] = tk.NORMAL 59 | 60 | def get_choice(self): 61 | return self.choice_var.get(), self.options.get(self.choice_var.get(), self.default) 62 | 63 | 64 | class ListboxChooser(ModalWindow): 65 | """ 66 | Usage Example: 67 | 68 | ```python 69 | listbox_chooser = ListboxChooser("Choose options", {"Option 1": 1, "Option 2": 2, "Option 3": 3}) 70 | for choice, value in listbox_chooser.get_choices(): 71 | print(choice, value) 72 | ``` 73 | """ 74 | 75 | def __init__(self, window_title: str, options: dict, default=None): 76 | super().__init__(window_title) 77 | self.exit_button = None 78 | self.choices = list(options.keys()) 79 | self.options = options 80 | self.default = default 81 | self.selection_var = tk.Variable(self) 82 | self.create_widgets() 83 | self.mainloop() 84 | 85 | def create_widgets(self): 86 | frame = ttk.Frame(self) 87 | frame.pack(padx=2, pady=2) 88 | 89 | listbox = tk.Listbox(frame, listvariable=tk.Variable(self, value=self.choices), selectmode="multiple", 90 | height=min(len(self.choices), 50), width=max(len(c) for c in self.choices)) 91 | listbox.pack(side=tk.LEFT, fill=tk.BOTH) 92 | 93 | scrollbar = ttk.Scrollbar(frame, command=listbox.yview) 94 | scrollbar.pack(side=tk.RIGHT, fill=tk.BOTH) 95 | listbox.config(yscrollcommand=scrollbar.set) 96 | 97 | listbox.bind("<>", self.on_select) 98 | 99 | self.exit_button = ttk.Button(self, text="OK", command=self.destroy, state=tk.DISABLED) 100 | self.exit_button.pack(pady=4, side=tk.BOTTOM) 101 | 102 | def on_select(self, event): 103 | self.exit_button['state'] = tk.NORMAL 104 | self.selection_var.set(event.widget.curselection()) 105 | 106 | def get_choices(self): 107 | for choice in self.selection_var.get(): 108 | yield self.choices[choice], self.options.get(self.choices[choice], self.default) 109 | -------------------------------------------------------------------------------- /examples/math/materializers.py: -------------------------------------------------------------------------------- 1 | import functools 2 | import json 3 | from collections.abc import Callable, Mapping 4 | from enum import Enum 5 | from typing import Optional 6 | 7 | from graphinate import GraphModel, GraphType, builders, graphql, matplotlib 8 | 9 | 10 | class Materializers(Enum): 11 | """Materializers Enum 12 | 13 | Attributes: 14 | D3Graph: create a D3 Graph and print it to stdout 15 | GraphQL: create a GraphQL Schema and serve it in a web server 16 | NetworkX: create a NetworkX Graph and plot+show it with matplotlib 17 | NetworkX_with_edge_labels: create a NetworkX Graph and plot+show it with matplotlib 18 | """ 19 | D3Graph: tuple = (builders.D3Builder, lambda d: print(json.dumps(d, indent=2, default=str))) 20 | GraphQL: tuple = (builders.GraphQLBuilder, graphql.server) 21 | NetworkX: tuple = (builders.NetworkxBuilder, matplotlib.plot) 22 | NetworkX_with_edge_labels: tuple = (builders.NetworkxBuilder, 23 | functools.partial(matplotlib.plot, with_edge_labels=True)) 24 | Mermaid: tuple = (builders.MermaidBuilder, print) 25 | 26 | 27 | def materialize(model: GraphModel, 28 | graph_type: GraphType = GraphType.Graph, 29 | default_node_attributes: Optional[Mapping] = None, 30 | builder: Optional[type[builders.Builder]] = None, 31 | builder_output_handler: Optional[Callable] = None, 32 | **kwargs): 33 | """ 34 | Materialize a GraphModel using a Builder and an Actualizer 35 | 36 | Args: 37 | model: GraphModel - the model to be materialized 38 | graph_type: GraphType - the type of graph to be built. 39 | Default is Graph. 40 | default_node_attributes: Mapping - A Mapping containing attributes that are added to all nodes. 41 | builder: Builder - the builder to be used to build the graph. 42 | builder_output_handler: function that will consume the resulting built graph and 43 | outputs it (e.g., display, serve, print, etc.). 44 | **kwargs: 45 | 46 | 47 | Returns: 48 | None 49 | """ 50 | if builder is None and builder_output_handler is None: 51 | raise ValueError("Missing: builder, builder_output_handler") 52 | 53 | if builder: 54 | graph = builders.build(builder, 55 | model, 56 | graph_type, 57 | default_node_attributes=default_node_attributes, 58 | **kwargs) 59 | 60 | if builder_output_handler and callable(builder_output_handler): 61 | builder_output_handler(graph, **kwargs) 62 | else: 63 | print(graph) 64 | -------------------------------------------------------------------------------- /examples/math/polygonal_graph.py: -------------------------------------------------------------------------------- 1 | import networkx as nx 2 | 3 | import graphinate 4 | 5 | # import graphinate.modeling 6 | from graphinate import GraphModel 7 | 8 | 9 | def polygonal_graph_edges(edges_count: int): 10 | for i in range(1, edges_count): 11 | yield {'source': i, 'target': i + 1} 12 | yield {'source': edges_count, 'target': 1} 13 | 14 | 15 | def polygonal_graph_model(name: str, number_of_sides: int) -> graphinate.GraphModel: 16 | """ 17 | Create a polygonal graph model. 18 | 19 | Args: 20 | name (str): The Graph's name. 21 | number_of_sides (int): Number of sides in the polygon. 22 | 23 | Returns: 24 | GraphModel: A graph model representing a polygonal graph. 25 | """ 26 | 27 | # Define GraphModel 28 | graph_model: GraphModel = graphinate.model(name) 29 | 30 | # Register edges supplier function 31 | @graph_model.edge() 32 | def edge(): 33 | yield from polygonal_graph_edges(number_of_sides) 34 | 35 | return graph_model 36 | 37 | # instantiated here to be used to cli serving 38 | model = polygonal_graph_model("Octagonal Graph", 8) 39 | 40 | if __name__ == '__main__': 41 | 42 | # 1. Define Graph Builder 43 | builder = graphinate.builders.NetworkxBuilder(model) 44 | 45 | # Then 46 | # 2. Build the Graph object 47 | graph: nx.Graph = builder.build() 48 | 49 | # Then 50 | # 3. Option A - Output to console 51 | print(graph) 52 | 53 | # Or 54 | # 3. Option B - Output as a plot 55 | graphinate.renderers.matplotlib.plot(graph) 56 | 57 | # Alternatively, 58 | # 4. Define a GraphQL Builder 59 | builder = graphinate.builders.GraphQLBuilder(model) 60 | 61 | schema = builder.build() 62 | 63 | graphinate.graphql.server(schema, port=9077) 64 | -------------------------------------------------------------------------------- /examples/math/requirements.txt: -------------------------------------------------------------------------------- 1 | graphinate 2 | networkx -------------------------------------------------------------------------------- /examples/social/cache/13/dd/73ce25face7beb30b69b64feeb77.val: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/erivlis/graphinate/f96201253aa713e78342f075ef990094e70bd698/examples/social/cache/13/dd/73ce25face7beb30b69b64feeb77.val -------------------------------------------------------------------------------- /examples/social/cache/21/9e/00846f323987ba16cfbe0127d8eb.val: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/erivlis/graphinate/f96201253aa713e78342f075ef990094e70bd698/examples/social/cache/21/9e/00846f323987ba16cfbe0127d8eb.val -------------------------------------------------------------------------------- /examples/social/cache/70/b6/2aefb0269adce7fedf877fa0d267.val: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/erivlis/graphinate/f96201253aa713e78342f075ef990094e70bd698/examples/social/cache/70/b6/2aefb0269adce7fedf877fa0d267.val -------------------------------------------------------------------------------- /examples/social/cache/87/f5/ec1739bc369e84c3fcb302bf532a.val: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/erivlis/graphinate/f96201253aa713e78342f075ef990094e70bd698/examples/social/cache/87/f5/ec1739bc369e84c3fcb302bf532a.val -------------------------------------------------------------------------------- /examples/social/cache/ba/fe/3aca7b2c38abff60e7ce5eb486a8.val: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/erivlis/graphinate/f96201253aa713e78342f075ef990094e70bd698/examples/social/cache/ba/fe/3aca7b2c38abff60e7ce5eb486a8.val -------------------------------------------------------------------------------- /examples/social/cache/c7/9e/ce82b0288020b7152779df09bd73.val: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/erivlis/graphinate/f96201253aa713e78342f075ef990094e70bd698/examples/social/cache/c7/9e/ce82b0288020b7152779df09bd73.val -------------------------------------------------------------------------------- /examples/social/cache/cache.db: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/erivlis/graphinate/f96201253aa713e78342f075ef990094e70bd698/examples/social/cache/cache.db -------------------------------------------------------------------------------- /examples/social/cache/d2/53/3b88f2fc162561cfdbbe9abc352a.val: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/erivlis/graphinate/f96201253aa713e78342f075ef990094e70bd698/examples/social/cache/d2/53/3b88f2fc162561cfdbbe9abc352a.val -------------------------------------------------------------------------------- /examples/social/cache/e2/d5/5d079f200eabf9b625b0473f6fbe.val: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/erivlis/graphinate/f96201253aa713e78342f075ef990094e70bd698/examples/social/cache/e2/d5/5d079f200eabf9b625b0473f6fbe.val -------------------------------------------------------------------------------- /examples/social/gui.py: -------------------------------------------------------------------------------- 1 | import platform 2 | import tkinter as tk 3 | from tkinter import ttk 4 | 5 | 6 | class ModalWindow(tk.Tk): 7 | def __init__(self, title: str): 8 | super().__init__() 9 | self.title(title) 10 | self.configure_window() 11 | 12 | def configure_window(self): 13 | if platform.system().lower() == 'windows': 14 | self.wm_attributes('-toolwindow', 'True') 15 | self.wm_attributes('-topmost', 'True') 16 | self.resizable(False, False) 17 | 18 | 19 | class RadiobuttonChooser(ModalWindow): 20 | """ 21 | Usage Example: 22 | ```python 23 | radiobutton_chooser = RadiobuttonChooser("Choose an option", {"Option 1": 1, "Option 2": 2}) 24 | choice, value = radiobutton_chooser.get_choice() 25 | print(choice, value) 26 | ``` 27 | """ 28 | 29 | def __init__(self, window_title: str, options: dict, default=None): 30 | super().__init__(window_title) 31 | self.exit_button = None 32 | self.choice_var = tk.StringVar(self, None) 33 | self.default = default 34 | self.options = options 35 | self.create_widgets() 36 | self.mainloop() 37 | 38 | def create_widgets(self): 39 | frame = ttk.Frame(self, borderwidth=5, relief='solid') 40 | frame.pack(padx=4, pady=4) 41 | 42 | ttk.Label(frame, text="Output Mode:").pack() 43 | 44 | for option in self.options: 45 | ttk.Radiobutton( 46 | frame, 47 | text=option, 48 | variable=self.choice_var, 49 | value=option, 50 | command=self.enable_exit_button, 51 | padding=4 52 | ).pack(side=tk.TOP, anchor="w") 53 | 54 | self.exit_button = ttk.Button(self, text="OK", command=self.destroy, state=tk.DISABLED) 55 | self.exit_button.pack(pady=4, side=tk.BOTTOM) 56 | 57 | def enable_exit_button(self): 58 | self.exit_button['state'] = tk.NORMAL 59 | 60 | def get_choice(self): 61 | return self.choice_var.get(), self.options.get(self.choice_var.get(), self.default) 62 | 63 | 64 | class ListboxChooser(ModalWindow): 65 | """ 66 | Usage Example: 67 | 68 | ```python 69 | listbox_chooser = ListboxChooser("Choose options", {"Option 1": 1, "Option 2": 2, "Option 3": 3}) 70 | for choice, value in listbox_chooser.get_choices(): 71 | print(choice, value) 72 | ``` 73 | """ 74 | 75 | def __init__(self, window_title: str, options: dict, default=None): 76 | super().__init__(window_title) 77 | self.exit_button = None 78 | self.choices = list(options.keys()) 79 | self.options = options 80 | self.default = default 81 | self.selection_var = tk.Variable(self) 82 | self.create_widgets() 83 | self.mainloop() 84 | 85 | def create_widgets(self): 86 | frame = ttk.Frame(self) 87 | frame.pack(padx=2, pady=2) 88 | 89 | listbox = tk.Listbox(frame, listvariable=tk.Variable(self, value=self.choices), selectmode="multiple", 90 | height=min(len(self.choices), 50), width=max(len(c) for c in self.choices)) 91 | listbox.pack(side=tk.LEFT, fill=tk.BOTH) 92 | 93 | scrollbar = ttk.Scrollbar(frame, command=listbox.yview) 94 | scrollbar.pack(side=tk.RIGHT, fill=tk.BOTH) 95 | listbox.config(yscrollcommand=scrollbar.set) 96 | 97 | listbox.bind("<>", self.on_select) 98 | 99 | self.exit_button = ttk.Button(self, text="OK", command=self.destroy, state=tk.DISABLED) 100 | self.exit_button.pack(pady=4, side=tk.BOTTOM) 101 | 102 | def on_select(self, event): 103 | self.exit_button['state'] = tk.NORMAL 104 | self.selection_var.set(event.widget.curselection()) 105 | 106 | def get_choices(self): 107 | for choice in self.selection_var.get(): 108 | yield self.choices[choice], self.options.get(self.choices[choice], self.default) 109 | -------------------------------------------------------------------------------- /examples/social/music_artists.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import operator 3 | import pathlib 4 | from functools import reduce 5 | from time import sleep 6 | 7 | import diskcache 8 | import musicbrainzngs 9 | 10 | import graphinate 11 | 12 | # logging.basicConfig(level=logging.INFO) 13 | logger = logging.getLogger(__name__) 14 | 15 | 16 | def initialize_musicbrainz(): 17 | musicbrainzngs.set_useragent( 18 | "MusicArtistGraph", 19 | "0.1.0", 20 | "https://github.com/erivlis/graphinate" 21 | ) 22 | 23 | 24 | initialize_musicbrainz() 25 | 26 | 27 | def cache_dir(): 28 | current_script_path = pathlib.Path(__file__).resolve() 29 | parent_dir = current_script_path.parent 30 | return (parent_dir / 'cache').as_posix() 31 | 32 | 33 | def music_graph_model(name: str, max_depth: int = 0): 34 | graph_model = graphinate.model(f"{name.capitalize()} Graph") 35 | 36 | artists_cache = diskcache.Cache(directory=cache_dir(), eviction_policy='none') 37 | 38 | result = musicbrainzngs.search_artists(query=name, strict=True, artist=name) 39 | sleep(1) 40 | root_artist = result.get('artist-list', [])[0] if result else None 41 | 42 | def artists(parent_artist, artist, depth): 43 | logger.info(f"Current depth: {depth}") 44 | artist_id = artist.get('id') 45 | if artist_id not in artists_cache: 46 | artists_cache[artist_id] = musicbrainzngs.get_artist_by_id(id=artist_id, includes=['artist-rels']).get( 47 | 'artist') 48 | sleep(0.1) 49 | 50 | artist = artists_cache.get(artist_id) 51 | 52 | yield parent_artist, artist 53 | 54 | if depth < max_depth: 55 | related_artist_ids = set() 56 | for item in artist.get('artist-relation-list', []): 57 | related_artist = item.get('artist') 58 | related_artist_id = related_artist.get('id') 59 | if related_artist_id not in related_artist_ids: 60 | related_artist_ids.add(related_artist_id) 61 | yield from artists(artist, related_artist, depth + 1) 62 | 63 | def artist_type(value): 64 | return value.get('type', '_UNKNOWN_') 65 | 66 | @graph_model.node(artist_type, 67 | key=operator.itemgetter('id'), 68 | label=operator.itemgetter('name'), 69 | multiplicity=graphinate.Multiplicity.FIRST) 70 | def node(): 71 | yielded = set() 72 | for a, b in artists(None, root_artist, 0): 73 | if a and ((a_id := a.get('id')) not in yielded): 74 | yielded.add(a_id) 75 | yield a 76 | if b and ((b_id := b.get('id')) not in yielded): 77 | yielded.add(b_id) 78 | yield b 79 | 80 | @graph_model.edge() 81 | def edge(): 82 | for a, b in artists(None, root_artist, 0): 83 | if a: 84 | yield {'source': a.get('id'), 'target': b.get('id')} 85 | 86 | return graph_model 87 | 88 | 89 | if __name__ == '__main__': 90 | from gui import ListboxChooser 91 | 92 | artist_names = [ 93 | 'Alice in Chains', 94 | 'Beatles', 95 | 'Caravan', 96 | 'Charles Mingus', 97 | 'Dave Brubeck', 98 | 'Dave Douglas', 99 | 'David Bowie', 100 | 'Deep Purple', 101 | 'Dire Straits', 102 | 'Emerson, Lake & Palmer', 103 | 'Foo Fighters', 104 | 'Frank Zappa', 105 | 'Genesis', 106 | 'Gentle Giant', 107 | 'Herbie Hancock', 108 | 'Jethro Tull', 109 | 'John Coltrane', 110 | 'John Scofield', 111 | 'John Zorn', 112 | 'Ken Vandermark', 113 | 'King Crimson', 114 | 'Led Zeppelin', 115 | 'Mahavishnu Orchestra', 116 | 'Miles Davis', 117 | 'Nirvana', 118 | 'Ornette Coleman', 119 | 'Paul McCartney', 120 | 'Pearl Jam', 121 | 'Pink Floyd', 122 | 'Police', 123 | 'Porcupine Tree', 124 | 'Radiohead', 125 | 'Red Hot Chili Peppers', 126 | 'Return to Forever', 127 | 'Rush', 128 | 'Smashing Pumpkins', 129 | 'Soft Machine', 130 | 'Soundgarden', 131 | 'Stone Temple Pilots', 132 | 'System of a Down', 133 | 'Thelonious Monk', 134 | 'Weather Report', 135 | 'Wings', 136 | 'Yes', 137 | ] 138 | 139 | listbox_chooser = ListboxChooser('Choose Artist/s', {name: name for name in artist_names}) 140 | 141 | models = (music_graph_model(a, 2) for _, a in listbox_chooser.get_choices()) 142 | 143 | model = reduce(operator.add, models) 144 | 145 | schema = graphinate.builders.GraphQLBuilder(model).build() 146 | graphinate.graphql.server(schema) 147 | -------------------------------------------------------------------------------- /examples/social/requirements.txt: -------------------------------------------------------------------------------- 1 | diskcache 2 | graphinate 3 | musicbrainzngs -------------------------------------------------------------------------------- /examples/system/files.py: -------------------------------------------------------------------------------- 1 | import fnmatch 2 | import operator 3 | import pathlib 4 | 5 | from magika import Magika 6 | 7 | import graphinate 8 | 9 | 10 | def load_ignore_patterns(ignore_files): 11 | patterns = set() 12 | for ignore_file in ignore_files: 13 | if pathlib.Path(ignore_file).exists(): 14 | with open(ignore_file) as file: 15 | patterns.update(line.strip() for line in file if line.strip() and not line.startswith('#')) 16 | 17 | expand_patterns = {f"**/*{p}" if p.startswith('/') else f"**/*/{p}" for p in patterns} 18 | patterns.update(expand_patterns) 19 | return patterns 20 | 21 | 22 | def is_ignored(path, patterns): 23 | return any(fnmatch.fnmatch(path.as_posix(), pattern) for pattern in patterns) 24 | 25 | 26 | def create_filesystem_graph_model(input_folder='.', ignore_files=['.ignore', '.gitignore', '.dockerignore']): 27 | """ 28 | Create a graph model of the file system structure. 29 | 30 | Args: 31 | input_folder (str): The folder to start the traversal from. Defaults to the current folder. 32 | ignore_files (list): A list of files containing ignore patterns. 33 | Defaults to ['.ignore', '.gitignore', '.dockerignore']. 34 | 35 | Returns: 36 | GraphModel: A graph model representing the file system structure. 37 | """ 38 | graph_model = graphinate.model(name="File System Graph") 39 | magika = Magika() 40 | 41 | root_folder = pathlib.Path(input_folder) 42 | ignore_patterns = load_ignore_patterns(ignore_files) 43 | 44 | def file_type(path: pathlib.Path) -> str: 45 | if path.is_file(): 46 | return magika.identify_path(path).output.ct_label 47 | elif path.is_dir(): 48 | return 'folder' 49 | else: 50 | return 'other' 51 | 52 | as_posix = operator.methodcaller('as_posix') 53 | 54 | @graph_model.node(file_type, key=as_posix, value=as_posix) 55 | def file_node(): 56 | yield root_folder 57 | for path in root_folder.rglob('*'): 58 | if not is_ignored(path, ignore_patterns, ): 59 | yield path 60 | 61 | @graph_model.edge() 62 | def contains(): 63 | for path in root_folder.rglob('*'): 64 | if not is_ignored(path, ignore_patterns): 65 | yield { 66 | 'source': path.parent.as_posix(), 67 | 'target': path.as_posix() 68 | } 69 | 70 | return graph_model 71 | 72 | 73 | if __name__ == '__main__': 74 | input_folder = '..' # Default to the current folder 75 | ignore_files = ['.ignore', '.gitignore'] # Example list of ignore files 76 | filesystem_model = create_filesystem_graph_model(input_folder, ignore_files) 77 | 78 | schema = graphinate.builders.GraphQLBuilder(filesystem_model).build() 79 | graphinate.graphql.server(schema) 80 | -------------------------------------------------------------------------------- /examples/system/processes.py: -------------------------------------------------------------------------------- 1 | import operator 2 | from collections.abc import Iterable 3 | 4 | import networkx as nx 5 | import psutil 6 | 7 | import graphinate 8 | 9 | 10 | def processes_graph_model(): 11 | """ 12 | Create a graph model representing processes and their parent-child relationships. 13 | 14 | Returns: 15 | GraphModel: A graph model representing processes and their parent-child relationships. 16 | """ 17 | 18 | graph_model = graphinate.model("Processes Graph") 19 | 20 | def processes() -> Iterable[psutil.Process]: 21 | for pid in psutil.pids(): 22 | if psutil.pid_exists(pid): 23 | yield psutil.Process(pid) 24 | 25 | processes_list = [ 26 | { 27 | 'pid': p.pid, 28 | 'name': p.name(), 29 | 'parent_pid': p.parent().pid if p.parent() else None 30 | } 31 | for p in processes() 32 | ] 33 | 34 | @graph_model.node(key=operator.itemgetter('pid'), label=operator.itemgetter('name')) 35 | def process(): 36 | yield from processes_list 37 | 38 | @graph_model.edge() 39 | def edge(): 40 | for p in processes_list: 41 | parent_pid = p.get('parent_pid') 42 | if parent_pid: 43 | yield {'source': p.get('pid'), 'target': parent_pid} 44 | 45 | return graph_model 46 | 47 | 48 | model = processes_graph_model() 49 | 50 | if __name__ == '__main__': 51 | # 1. Define Graph Builder 52 | builder = graphinate.builders.NetworkxBuilder(model=model) 53 | 54 | # Then 55 | # 2. Build the Graph object 56 | graph: nx.Graph = builder.build() 57 | 58 | # Then 59 | # 3. Option A - Output to console 60 | print(graph) 61 | 62 | # Or 63 | # 3. Option B - Output as a plot 64 | graphinate.materializers.plot(graph) 65 | -------------------------------------------------------------------------------- /examples/system/requirements.txt: -------------------------------------------------------------------------------- 1 | graphinate 2 | magika 3 | psutil 4 | -------------------------------------------------------------------------------- /examples/web/html_dom.py: -------------------------------------------------------------------------------- 1 | import base64 2 | 3 | import requests 4 | from bs4 import BeautifulSoup, Tag 5 | 6 | import graphinate 7 | 8 | 9 | def load_html_from_url(url="https://www.google.com"): 10 | response = requests.get(url) 11 | return response.text 12 | 13 | 14 | def load_html(file_path): 15 | with open(file_path) as file: 16 | return file.read() 17 | 18 | 19 | def html_dom_graph_model(html_content): 20 | graph_model = graphinate.model(name="HTML DOM Graph") 21 | soup = BeautifulSoup(html_content, 'html.parser') 22 | 23 | def node_type(tag: Tag): 24 | return tag.name.strip('[]') 25 | 26 | def node_key(tag: Tag): 27 | return str((tag.sourceline, tag.sourcepos)) if isinstance(tag, Tag) else base64.b64encode( 28 | tag.encode()).decode() 29 | 30 | def node_label(tag: Tag): 31 | return str(tag) 32 | 33 | @graph_model.node(node_type, key=node_key, label=node_label) 34 | def html_node(): 35 | for tag in soup.descendants: 36 | if tag.name is not None: 37 | yield tag 38 | 39 | @graph_model.edge() 40 | def contains(): 41 | for tag in soup.descendants: 42 | if tag.name is not None: 43 | for child in tag.children: 44 | if child.name is not None: 45 | yield { 46 | 'source': node_key(tag), 47 | 'target': node_key(child) 48 | } 49 | 50 | return graph_model 51 | 52 | 53 | if __name__ == '__main__': 54 | html_content = load_html_from_url() 55 | dom_model = html_dom_graph_model(html_content) 56 | schema = graphinate.builders.GraphQLBuilder(dom_model).build() 57 | graphinate.graphql.server(schema) 58 | -------------------------------------------------------------------------------- /examples/web/page_links.py: -------------------------------------------------------------------------------- 1 | from urllib.parse import urlparse 2 | 3 | import requests 4 | from bs4 import BeautifulSoup 5 | from loguru import logger 6 | 7 | import graphinate 8 | 9 | DEFAULT_MAX_DEPTH = 0 10 | 11 | 12 | def page_links_graph_model(max_depth: int = DEFAULT_MAX_DEPTH): 13 | """ 14 | Create a graph model based on page links. 15 | 16 | Args: 17 | max_depth (int, optional): The maximum depth to crawl for page links. Defaults to DEFAULT_MAX_DEPTH. 18 | 19 | Returns: 20 | GraphModel: A graph model representing the page links. 21 | """ 22 | 23 | def _links(url: str, depth=0, **kwargs): 24 | reqs = requests.get(url) 25 | logger.debug('Analyzing Page: {url}') 26 | soup = BeautifulSoup(reqs.text, 'lxml') 27 | logger.debug('Done Analyzing Page: {url}') 28 | for link in soup.find_all('a', href=True): 29 | child_url = link.get('href') 30 | 31 | if child_url.startswith('javascript:'): # Skip JavaScript links 32 | continue 33 | 34 | if child_url.startswith('//'): # Handle protocol-relative URLs 35 | child_url = f"https:{child_url}" 36 | 37 | if not bool(urlparse(child_url).netloc): # Skip relative URLs 38 | # child_url = urljoin(url, child_url) 39 | continue 40 | 41 | if not child_url.startswith('http'): # Skip non-HTTP URLs 42 | continue 43 | 44 | yield {'source': url, 'target': child_url} 45 | if depth < max_depth: 46 | yield from _links(child_url, depth=depth + 1, **kwargs) 47 | 48 | graph_model = graphinate.model(name='Web') 49 | 50 | @graph_model.edge() 51 | def link(url, **kwargs): 52 | yield from _links(url, **kwargs) 53 | 54 | return graph_model 55 | 56 | 57 | if __name__ == '__main__': 58 | model = page_links_graph_model(1) 59 | 60 | params = { 61 | # 'url': 'https://github.com/erivlis/graphinate' 62 | 'url': 'https://erivlis.github.io/graphinate/' 63 | } 64 | 65 | builder = graphinate.builders.GraphQLBuilder(model, graph_type=graphinate.GraphType.DiGraph) 66 | schema = builder.build(default_node_attributes={'type': 'url'}, **params) 67 | graphinate.graphql.server(schema) 68 | -------------------------------------------------------------------------------- /examples/web/requirements.txt: -------------------------------------------------------------------------------- 1 | beautifulsoup4 2 | loguru 3 | lxml 4 | requests 5 | -------------------------------------------------------------------------------- /mkdocs.yml: -------------------------------------------------------------------------------- 1 | # yaml-language-server: $schema=https://squidfunk.github.io/mkdocs-material/schema.json 2 | 3 | site_name: "Graphinate" 4 | site_author: Eran Rivlis 5 | site_description: >- 6 | Data to Graphs. 7 | 8 | repo_url: https://github.com/erivlis/graphinate 9 | repo_name: erivlis/graphinate 10 | edit_uri: edit/main/docs/ 11 | 12 | # Copyright 13 | copyright: Copyright © 2023-2025 Eran Rivlis 14 | 15 | theme: 16 | name: material 17 | features: 18 | - content.action.edit 19 | - content.action.view 20 | - content.code.annotate 21 | - content.code.copy 22 | - content.code.select 23 | - navigation.tabs 24 | - navigation.tabs.sticky 25 | - navigation.path 26 | - navigation.sections 27 | - navigation.footer 28 | - navigation.indexes 29 | - navigation.instant 30 | - navigation.instant.progress 31 | - navigation.prune 32 | - navigation.top 33 | - navigation.tracking 34 | - search.highlight 35 | - search.share 36 | - search.suggest 37 | - toc.follow 38 | font: false 39 | palette: 40 | # Palette toggle for dark mode 41 | - scheme: slate 42 | primary: custom 43 | accent: deep purple 44 | toggle: 45 | icon: material/weather-night 46 | name: Switch to light mode 47 | # Palette toggle for light mode 48 | - scheme: default 49 | primary: custom 50 | accent: deep purple 51 | toggle: 52 | icon: material/weather-sunny 53 | name: Switch to dark mode 54 | 55 | logo: assets/images/logo-128.png 56 | favicon: assets/images/logo-128.png 57 | extra_css: 58 | - assets/stylesheets/extra.css 59 | 60 | 61 | markdown_extensions: 62 | - admonition 63 | - attr_list 64 | - md_in_html 65 | - pymdownx.details 66 | - pymdownx.highlight: 67 | # auto_title: true 68 | anchor_linenums: true 69 | line_spans: __span 70 | use_pygments: true 71 | pygments_lang_class: true 72 | - pymdownx.emoji: 73 | emoji_index: !!python/name:material.extensions.emoji.twemoji 74 | emoji_generator: !!python/name:material.extensions.emoji.to_svg 75 | - pymdownx.inlinehilite 76 | - pymdownx.snippets 77 | - pymdownx.superfences: 78 | custom_fences: 79 | - name: mermaid 80 | class: mermaid 81 | format: !!python/name:pymdownx.superfences.fence_code_format 82 | - pymdownx.tabbed: 83 | alternate_style: true 84 | - toc: 85 | permalink: true 86 | 87 | 88 | nav: 89 | # - Home: index.md 90 | - Introduction: intro.md 91 | - Quick Start: start.md 92 | - Tutorial: tutorial.md 93 | - Usage: usage/ 94 | - Examples: examples/ 95 | # - Gallery: gallery/ 96 | # defer to gen-files + literate-nav 97 | - Reference: reference/ 98 | - Acknowledgements: acknowledge.md 99 | 100 | 101 | extra: 102 | generator: false 103 | social: 104 | - icon: fontawesome/brands/github 105 | link: https://github.com/erivlis/graphinate 106 | - icon: fontawesome/brands/python 107 | link: https://pypi.org/project/graphinate 108 | - icon: fontawesome/brands/mastodon 109 | link: https://mastodon.social/@erivlis 110 | - icon: fontawesome/brands/bluesky 111 | link: https://bsky.app/profile/erivlis.bsky.social 112 | - icon: fontawesome/brands/x-twitter 113 | link: https://x.com/erivlis 114 | - icon: fontawesome/brands/linkedin 115 | link: https://www.linkedin.com/in/eranrivlis 116 | 117 | 118 | plugins: 119 | - search 120 | - gen-files: 121 | scripts: 122 | - docs/gen_ref_pages.py 123 | - glightbox 124 | - literate-nav: 125 | nav_file: SUMMARY.md 126 | - git-committers: 127 | repository: erivlis/graphinate 128 | branch: main 129 | - git-revision-date-localized: 130 | enable_creation_date: true 131 | - mkdocstrings: 132 | default_handler: python 133 | handlers: 134 | python: 135 | paths: [ src ] 136 | options: 137 | allow_inspection: true 138 | show_source: false 139 | heading_level: 3 140 | # show_root_heading: false 141 | show_category_heading: true 142 | show_symbol_type_heading: true 143 | show_symbol_type_toc: true -------------------------------------------------------------------------------- /playground/ethernet/traceroute.py: -------------------------------------------------------------------------------- 1 | import socket 2 | 3 | from scapy.layers.inet import traceroute 4 | 5 | # target = ["192.168.1.254"] 6 | # target = ["1.1.1.1"] 7 | # target = ["8.8.8.8"] 8 | target = ["www.google.com"] 9 | # for i in range(3): 10 | traceroute_result, packets = traceroute(target, maxttl=32) 11 | 12 | # print(traceroute_result, "\n") 13 | for packet in packets: 14 | ip_address = packet.src 15 | hostname = socket.gethostbyaddr(ip_address) 16 | # print(packet, "\t", hostname) 17 | -------------------------------------------------------------------------------- /playground/genric_graph.graphql: -------------------------------------------------------------------------------- 1 | query GenericGraph { 2 | graph { 3 | data 4 | nodes { 5 | id 6 | name: label 7 | type 8 | label 9 | magnitude 10 | color 11 | lineage 12 | created 13 | updated 14 | } 15 | links: edges { 16 | source 17 | target 18 | type 19 | label 20 | # value 21 | weight 22 | color 23 | # created 24 | updated 25 | 26 | } 27 | } 28 | } -------------------------------------------------------------------------------- /playground/graphql.config.yml: -------------------------------------------------------------------------------- 1 | schema: http://localhost:8000/graphql 2 | -------------------------------------------------------------------------------- /playground/house_of_graphs.py: -------------------------------------------------------------------------------- 1 | from collections.abc import Iterable, Mapping 2 | from pprint import pprint 3 | from typing import Union 4 | 5 | import networkx as nx 6 | 7 | 8 | def parse(adjacency_list: str) -> Iterable[tuple[int, list[int]]]: 9 | for line in adjacency_list.strip().splitlines(): 10 | s, tl = line.split(":") 11 | yield int(s), [int(t) for t in tl.strip().split()] 12 | 13 | 14 | def adjacency_mapping(adjacency_list: str) -> Mapping[int, list[int]]: 15 | return dict(parse(adjacency_list)) 16 | 17 | 18 | def edges_iter(adjacency_source: Union[str, Mapping[int, list[int]]]) -> Iterable[tuple[int, int]]: 19 | if isinstance(adjacency_source, str): 20 | adjacency_list = parse(adjacency_source) 21 | elif isinstance(adjacency_source, Mapping): 22 | adjacency_list = adjacency_source.items() 23 | else: 24 | raise TypeError("'adjacency_source' should be a 'str' or a Mapping[int, list[int]]") 25 | 26 | for s, tl in adjacency_list: 27 | for t in tl: 28 | yield int(s), int(t) 29 | 30 | 31 | def graph(adjacency_source: Union[str, Mapping[int, list[int]]]) -> nx.Graph(): 32 | return nx.Graph(list(edges_iter(adjacency_source))) 33 | 34 | 35 | if __name__ == '__main__': 36 | a = """ 37 | 1: 4 15 38 | 2: 8 17 39 | 3: 8 17 40 | 4: 1 14 41 | 5: 7 14 42 | 6: 7 14 43 | 7: 5 6 44 | 8: 2 3 45 | 9: 11 16 46 | 10: 13 15 47 | 11: 9 12 48 | 12: 11 16 17 49 | 13: 10 16 17 50 | 14: 4 5 6 15 51 | 15: 1 10 14 16 52 | 16: 9 12 13 15 53 | 17: 2 3 12 13 54 | """ 55 | 56 | m = adjacency_mapping(a) 57 | 58 | pprint(m) 59 | -------------------------------------------------------------------------------- /playground/science/caffeine.py: -------------------------------------------------------------------------------- 1 | import graphinate 2 | 3 | 4 | def caffeine_graph_model(): 5 | """ 6 | Create a graph model for the caffeine molecule (C8H10N4O2). 7 | 8 | Returns: 9 | GraphModel: A graph model representing the caffeine molecule. 10 | """ 11 | graph_model = graphinate.model(name="Caffeine Molecule") 12 | 13 | # Define atoms 14 | atoms = [ 15 | ('C1', 'C'), ('C2', 'C'), ('C3', 'C'), ('C4', 'C'), ('C5', 'C'), ('C6', 'C'), 16 | ('C7', 'C'), ('C8', 'C'), ('H1', 'H'), ('H2', 'H'), ('H3', 'H'), ('H4', 'H'), 17 | ('H5', 'H'), ('H6', 'H'), ('H7', 'H'), ('H8', 'H'), ('H9', 'H'), ('H10', 'H'), 18 | ('N1', 'N'), ('N2', 'N'), ('N3', 'N'), ('N4', 'N'), ('O1', 'O'), ('O2', 'O') 19 | ] 20 | 21 | # Define bonds 22 | bonds = [ 23 | ('C1', 'C2'), ('C1', 'N1'), ('C1', 'H1'), ('C2', 'C3'), ('C2', 'N2'), ('C3', 'C4'), 24 | ('C6', 'C7'), ('C7', 'C8'), ('C7', 'H4'), ('C8', 'O1'), ('C8', 'O2'), ('N1', 'H5'), 25 | ('N2', 'H6'), ('N3', 'H7'), ('N4', 'H8'), ('O1', 'H9'), ('O2', 'H10') 26 | ] 27 | 28 | # Add nodes (atoms) 29 | @graph_model.node(lambda x: x[1], key=lambda x: x[0], label=lambda x: x[1]) 30 | def atom(): 31 | yield from atoms 32 | 33 | @graph_model.edge() 34 | def bond(): 35 | for bond in bonds: 36 | yield {'source': bond[0], 'target': bond[1]} 37 | 38 | return graph_model 39 | 40 | 41 | if __name__ == '__main__': 42 | model = caffeine_graph_model() 43 | schema = graphinate.builders.GraphQLBuilder(model).build() 44 | graphinate.graphql.server(schema) 45 | -------------------------------------------------------------------------------- /playground/social/musicisians.py: -------------------------------------------------------------------------------- 1 | import json 2 | from collections import defaultdict 3 | 4 | with open("albums.json") as f: 5 | albums = json.load(f) 6 | 7 | artist_titles = defaultdict(list) 8 | title_performers = defaultdict(set) 9 | performer_instruments = defaultdict(set) 10 | for album in albums: 11 | artist_titles[album.get('Artist')].append(album.get('Title')) 12 | performers = [tuple(p.strip().split(':')) for p in album.get('Performer').split('/')] 13 | for items in performers: 14 | performer = items[0].strip() 15 | title_performers[album.get('Title')].add(performer) 16 | 17 | if len(items) > 1: 18 | for instrument in items[1].strip().split(','): 19 | performer_instruments[performer].add(instrument) 20 | 21 | print('a') 22 | -------------------------------------------------------------------------------- /playground/text/nlp_graph.py: -------------------------------------------------------------------------------- 1 | import spacy 2 | 3 | nlp = spacy.load("en_core_web_sm") 4 | doc = nlp("Apple is looking at buying U.K. startup for $1 billion") 5 | for token in doc: 6 | print(token.text, token.pos_, token.dep_) 7 | 8 | doc = nlp("Autonomous cars shift insurance liability toward manufacturers") 9 | for chunk in doc.noun_chunks: 10 | print(chunk.text, chunk.root.text, chunk.root.dep_, 11 | chunk.root.head.text) 12 | 13 | for token in doc: 14 | print(token.text, token.dep_, token.head.text, token.head.pos_, list(token.children)) 15 | -------------------------------------------------------------------------------- /playground/text/requirements.txt: -------------------------------------------------------------------------------- 1 | spacy==3.7.0 -------------------------------------------------------------------------------- /playground/time_series/requirements.txt: -------------------------------------------------------------------------------- 1 | more-itertools>=10.1.0 -------------------------------------------------------------------------------- /playground/time_series/visibility_graph.py: -------------------------------------------------------------------------------- 1 | import itertools 2 | from collections.abc import Iterable 3 | from typing import Optional 4 | 5 | import more_itertools 6 | import networkx as nx 7 | from matplotlib import pyplot 8 | 9 | 10 | def sliding_window_visibility_graph(series: Iterable[float], window_size: Optional[int] = None): 11 | if window_size: 12 | yield from itertools.chain( 13 | visibility_graph(subseries) for subseries in more_itertools.sliding_window(series, window_size)) 14 | else: 15 | yield visibility_graph(series) 16 | 17 | 18 | def obstruction_predicate(n1, t1, n2, t2): 19 | slope = (t2 - t1) / (n2 - n1) 20 | constant = t2 - slope * n2 21 | 22 | def is_obstruction(n, t): 23 | return t >= constant + slope * n 24 | 25 | return is_obstruction 26 | 27 | 28 | def visibility_graph(series: Iterable[float]): 29 | graph = nx.Graph() 30 | # Check all combinations of nodes n series 31 | 32 | for s1, s2 in itertools.combinations(enumerate(series), 2): 33 | n1, t1 = s1 34 | n2, t2 = s2 35 | 36 | if n2 == n1 + 1: 37 | graph.add_node(n1, value=t1) 38 | graph.add_node(n2, value=t2) 39 | graph.add_edge(n1, n2) 40 | else: 41 | is_obstruction = obstruction_predicate(n1, t1, n2, t2) 42 | obstructed = any(is_obstruction(n, t) for n, t in enumerate(series) if n1 < n < n2) 43 | if not obstructed: 44 | graph.add_edge(n1, n2) 45 | 46 | return graph 47 | 48 | 49 | if __name__ == '__main__': 50 | series_list = [ 51 | # range(10), 52 | # [2, 1, 3, 2, 1, 3, 2, 1, 3, 2, 1, 3, 4], 53 | [-(x ** 3) for x in range(-10, 11)], 54 | # [2, 1, 3, 4, 2, 1, 4, 3, 1, 1, 3, 4, 2, 4, 1, 3], 55 | # random.sample(range(1000), 500) 56 | ] 57 | 58 | for s in series_list: 59 | g = visibility_graph(s) 60 | print(g) 61 | 62 | pos = [[x, 0] for x in range(len(s))] 63 | labels = nx.get_node_attributes(g, 'value') 64 | nx.draw_networkx_nodes(g, pos) 65 | nx.draw_networkx_labels(g, pos, labels=labels) 66 | nx.draw_networkx_edges(g, pos, arrows=True, connectionstyle='arc3,rad=-1.57079632679') 67 | pyplot.show() 68 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [project] 2 | name = "Graphinate" 3 | version = "0.8.5" 4 | description = "Graphinate. Data to Graphs." 5 | authors = [ 6 | { name = "Eran Rivlis", email = "eran@rivlis.info" }, 7 | ] 8 | readme = "README.md" 9 | requires-python = ">=3.10" 10 | classifiers = [ 11 | "Development Status :: 4 - Beta", 12 | "Intended Audience :: Developers", 13 | "Intended Audience :: Information Technology", 14 | "Intended Audience :: Science/Research", 15 | "Programming Language :: Python :: 3", 16 | "Programming Language :: Python :: 3.10", 17 | "Programming Language :: Python :: 3.11", 18 | "Programming Language :: Python :: 3.12", 19 | "Programming Language :: Python :: 3.13", 20 | "Programming Language :: Python :: 3 :: Only", 21 | "Programming Language :: Python :: Implementation :: CPython", 22 | "License :: OSI Approved :: GNU Lesser General Public License v3 or later (LGPLv3+)", 23 | "Natural Language :: English", 24 | "Operating System :: OS Independent", 25 | "Topic :: Scientific/Engineering", 26 | "Topic :: Software Development :: Libraries", 27 | "Typing :: Typed" 28 | ] 29 | keywords = ['graph', 'declarative'] 30 | dependencies = [ 31 | "click", 32 | "inflect", 33 | "loguru", 34 | "mappingtools", 35 | "matplotlib", 36 | "networkx", 37 | "networkx-mermaid", 38 | "strawberry-graphql[asgi,opentelemetry]", 39 | ] 40 | 41 | [project.optional-dependencies] 42 | plot = [ 43 | "scipy" 44 | ] 45 | server = [ 46 | "starlette-prometheus", 47 | "uvicorn[standard]" 48 | ] 49 | 50 | [project.urls] 51 | "Homepage" = "https://erivlis.github.io/graphinate" 52 | "Documentation" = "https://erivlis.github.io/graphinate" 53 | "Bug Tracker" = "https://github.com/erivlis/graphinate/issues" 54 | "Source" = "https://github.com/erivlis/graphinate" 55 | 56 | 57 | [dependency-groups] 58 | dev = [ 59 | "pipdeptree", 60 | "ruff", 61 | "uv" 62 | ] 63 | mdformat = [ 64 | "mdformat", 65 | "mdformat-admon", 66 | "mdformat-config", 67 | "mdformat-footnote", 68 | "mdformat-frontmatter", 69 | "mdformat-gfm", 70 | "mdformat-gfm-alerts", 71 | "mdformat-ruff", 72 | "mdformat-tables" 73 | ] 74 | docs = [ 75 | "mkdocs-material", 76 | "mkdocstrings-python", 77 | "mkdocs-git-committers-plugin-2", 78 | "mkdocs-git-revision-date-localized-plugin<1.4.0", 79 | "mkdocs-gen-files", 80 | "mkdocs-glightbox", 81 | "mkdocs-literate-nav", 82 | "mkdocs-section-index", 83 | ] 84 | test = [ 85 | "faker", 86 | "httpx", 87 | "pytest", 88 | "pytest-asyncio", 89 | "pytest-cov", 90 | "pytest-mock", 91 | "pytest-randomly", 92 | "pytest-xdist" 93 | ] 94 | 95 | [build-system] 96 | requires = ["hatchling"] 97 | build-backend = "hatchling.build" 98 | 99 | [tool.hatch.metadata] 100 | allow-direct-references = true 101 | 102 | [tool.hatch.envs.default] 103 | installer = "uv" 104 | 105 | #[tool.hatch.build.targets.wheel] 106 | #packages = ["src/graphinate"] 107 | 108 | [tool.pytest.ini_options] 109 | pythonpath = ["src"] 110 | testpaths = ["tests"] 111 | 112 | [tool.coverage.report] 113 | exclude_also = [ 114 | "...", 115 | "def __repr__", 116 | "if self.debug:", 117 | "if settings.DEBUG", 118 | "raise AssertionError", 119 | "raise NotImplementedError", 120 | "if 0:", 121 | "if __name__ == .__main__.:", 122 | "if TYPE_CHECKING:", 123 | "class .*\\bProtocol\\):", 124 | "@(abc\\.)?abstractmethod" 125 | ] 126 | 127 | 128 | [tool.black] 129 | line-length = 120 130 | #extend-exclude = '' 131 | 132 | [tool.ruff] 133 | line-length = 120 134 | 135 | [tool.ruff.lint] 136 | select = [ 137 | "E", 138 | "F", 139 | "W", 140 | "C90", 141 | "I", 142 | "N", 143 | "U", 144 | "C4", 145 | "PIE", 146 | "PT", 147 | "SIM", 148 | # "ERA", 149 | "TRY", 150 | "RUF", 151 | ] 152 | ignore = ["TRY003", "UP007", "UP038", "RUF100"] 153 | # Exclude a variety of commonly ignored directories. 154 | exclude = [ 155 | ".bzr", 156 | ".direnv", 157 | ".eggs", 158 | ".git", 159 | ".git-rewrite", 160 | ".hg", 161 | ".idea", 162 | ".mypy_cache", 163 | ".nox", 164 | ".pants.d", 165 | ".pytype", 166 | ".ruff_cache", 167 | ".svn", 168 | ".tox", 169 | ".venv", 170 | "__pypackages__", 171 | "_build", 172 | "buck-out", 173 | "build", 174 | "dist", 175 | "venv", 176 | ] 177 | # Allow unused variables when underscore-prefixed. 178 | dummy-variable-rgx = "^(_+|(_+[a-zA-Z0-9_]*[a-zA-Z0-9]+?))$" 179 | 180 | 181 | [tool.ruff.lint.flake8-quotes] 182 | docstring-quotes = "double" 183 | inline-quotes = "single" 184 | multiline-quotes = "single" 185 | 186 | 187 | [tool.ruff.lint.mccabe] 188 | # Unlike Flake8, default to a complexity level of 10. 189 | max-complexity = 15 190 | -------------------------------------------------------------------------------- /sonar-project.properties: -------------------------------------------------------------------------------- 1 | sonar.organization=erivlis 2 | sonar.projectKey=erivlis_graphinate 3 | sonar.python.coverage.reportPaths=coverage.xml 4 | sonar.python.xunit.reportPath=test_results.xml -------------------------------------------------------------------------------- /src/graphinate/__init__.py: -------------------------------------------------------------------------------- 1 | from graphinate.builders import GraphType, build 2 | from graphinate.modeling import GraphModel, Multiplicity, model 3 | from graphinate.renderers import graphql, matplotlib, mermaid 4 | 5 | from . import builders, renderers 6 | 7 | __all__ = ( 8 | 'GraphModel', 9 | 'GraphType', 10 | 'Multiplicity', 11 | 'build', 12 | 'builders', 13 | 'graphql', 14 | 'matplotlib', 15 | 'mermaid', 16 | 'model', 17 | 'renderers' 18 | ) 19 | -------------------------------------------------------------------------------- /src/graphinate/__main__.py: -------------------------------------------------------------------------------- 1 | from .cli import cli # pragma: no cover 2 | 3 | if __name__ == '__main__': # pragma: no cover 4 | cli() 5 | -------------------------------------------------------------------------------- /src/graphinate/cli.py: -------------------------------------------------------------------------------- 1 | import importlib 2 | import json 3 | from typing import Any 4 | 5 | import click 6 | 7 | from graphinate import GraphModel, builders, graphql 8 | from graphinate.renderers.graphql import DEFAULT_PORT 9 | 10 | 11 | def _get_kwargs(ctx) -> dict: 12 | return dict([item.strip('--').split('=') for item in ctx.args if item.startswith("--")]) 13 | 14 | 15 | def import_from_string(import_str: Any) -> Any: 16 | """Import an object from a string reference {module-name}:{variable-name} 17 | For example, if `model=GraphModel()` is defined in app.py file, then the 18 | reference would be app:model. 19 | """ 20 | 21 | if not isinstance(import_str, str): 22 | return import_str 23 | 24 | module_str, _, attrs_str = import_str.partition(":") 25 | if not module_str or not attrs_str: 26 | message = f"Import string '{import_str}' must be in format ':'." 27 | raise ImportFromStringError(message) 28 | 29 | try: 30 | module = importlib.import_module(module_str) 31 | except ModuleNotFoundError as exc: 32 | if exc.name != module_str: 33 | raise exc from None 34 | message = f"Could not import module '{module_str}'." 35 | raise ImportFromStringError(message) from exc 36 | 37 | instance = module 38 | try: 39 | for attr_str in attrs_str.split("."): 40 | instance = getattr(instance, attr_str) 41 | except AttributeError as exc: 42 | message = f"Attribute '{attrs_str}' not found in module '{module_str}'." 43 | raise ImportFromStringError(message) from exc 44 | 45 | return instance 46 | 47 | 48 | class ImportFromStringError(Exception): 49 | pass 50 | 51 | 52 | class GraphModelType(click.ParamType): 53 | name = "MODEL" 54 | 55 | def convert(self, value, param, ctx) -> GraphModel: 56 | if isinstance(value, GraphModel): 57 | return value 58 | 59 | try: 60 | return import_from_string(value) if isinstance(value, str) else value 61 | except Exception as e: 62 | self.fail(str(e)) 63 | 64 | 65 | model_option = click.option('-m', '--model', 66 | type=GraphModelType(), 67 | help="A GraphModel instance reference {module-name}:{GraphModel-instance-variable-name}" 68 | " For example given a var `model=GraphModel()` defined in app.py file, then the" 69 | " reference would be app:model") 70 | 71 | 72 | @click.group() 73 | @click.pass_context 74 | def cli(ctx): 75 | pass 76 | 77 | 78 | @cli.command() 79 | @model_option 80 | @click.pass_context 81 | def save(ctx, model): 82 | kwargs = _get_kwargs(ctx) 83 | with open(f"{model.name}.d3_graph.json", mode='w') as fp: 84 | graph = builders.D3Builder(model, **kwargs).build() 85 | json.dump(graph, fp=fp, default=str, **kwargs) 86 | 87 | 88 | @cli.command() 89 | @model_option 90 | @click.option('-p', '--port', type=int, default=DEFAULT_PORT, help='Port number.') 91 | @click.option('-b', '--browse', type=bool, default=False, help='Open server address in browser.') 92 | @click.pass_context 93 | def server(ctx, model: GraphModel, port: int, browse: bool): 94 | message = """ 95 | ██████╗ ██████╗ █████╗ ██████╗ ██╗ ██╗██╗███╗ ██╗ █████╗ ████████╗███████╗ 96 | ██╔════╝ ██╔══██╗██╔══██╗██╔══██╗██║ ██║██║████╗ ██║██╔══██╗╚══██╔══╝██╔════╝ 97 | ██║ ███╗██████╔╝███████║██████╔╝███████║██║██╔██╗ ██║███████║ ██║ █████╗ 98 | ██║ ██║██╔══██╗██╔══██║██╔═══╝ ██╔══██║██║██║╚██╗██║██╔══██║ ██║ ██╔══╝ 99 | ╚██████╔╝██║ ██║██║ ██║██║ ██║ ██║██║██║ ╚████║██║ ██║ ██║ ███████╗ 100 | ╚═════╝ ╚═╝ ╚═╝╚═╝ ╚═╝╚═╝ ╚═╝ ╚═╝╚═╝╚═╝ ╚═══╝╚═╝ ╚═╝ ╚═╝ ╚══════╝""" 101 | click.echo(message) 102 | schema = builders.GraphQLBuilder(model).build() 103 | graphql(schema, port=port, browse=browse, **_get_kwargs(ctx)) 104 | -------------------------------------------------------------------------------- /src/graphinate/color.py: -------------------------------------------------------------------------------- 1 | import functools 2 | from collections.abc import Mapping, Sequence 3 | from typing import Union 4 | 5 | import matplotlib as mpl 6 | import networkx as nx 7 | 8 | 9 | @functools.lru_cache 10 | def node_color_mapping(graph: nx.Graph, cmap: Union[str, mpl.colors.Colormap] = "tab20") -> Mapping: 11 | """Map node types to RGBA colors based on a colormap. 12 | Args: 13 | graph: nx.Graph - The input graph for which node colors need to be mapped. 14 | cmap: Union[str, mpl.colors.Colormap], optional - The colormap used to map values to RGBA colors. 15 | Default is "tab20". 16 | Returns: 17 | Mapping - A dictionary mapping nodes to their corresponding RGBA colors based on the colormap. 18 | 19 | .. note:: 20 | The graph should have a 'node_types' attribute containing the types of nodes. 21 | The colormap can be specified as a string or a matplotlib colormap object. 22 | """ 23 | 24 | node_types = graph.graph.get('node_types', {}) 25 | 26 | if len(node_types) > 1 and 'node' in node_types: 27 | node_types.pop('node') 28 | 29 | type_lookup = {t: i for i, t in enumerate(graph.graph['node_types'].keys())} 30 | color_lookup = {node: type_lookup.get(data.get('type'), 0) for node, data in graph.nodes.data()} 31 | if len(color_lookup) > 1: 32 | low, *_, high = sorted(color_lookup.values()) 33 | else: 34 | low = high = 0 35 | norm = mpl.colors.Normalize(vmin=low, vmax=high, clip=True) 36 | mapper = mpl.cm.ScalarMappable(norm=norm, cmap=cmap) 37 | node_colors = {n: mapper.to_rgba(i) for n, i in color_lookup.items()} 38 | return node_colors 39 | 40 | 41 | def color_hex(color: Union[str, Sequence[Union[float, int]]]) -> Union[str, Sequence[Union[float, int]]]: 42 | """Get HEX color code 43 | 44 | Args: 45 | color: input color 46 | Returns: 47 | Color HEX code 48 | 49 | .. note:: 50 | If the input is a tuple or list, it should contain either three floats (0-1) or three ints (0-255). 51 | The function will convert these to a HEX color code. 52 | """ 53 | if isinstance(color, (tuple, list)): # noqa: UP038 54 | rgb = color[:3] 55 | 56 | if all(isinstance(c, float) and 0 <= c <= 1 for c in rgb): 57 | rgb = tuple(int(c * 255) for c in rgb) 58 | elif all(isinstance(c, int) and 0 <= c <= 255 for c in rgb): 59 | rgb = tuple(rgb) 60 | else: 61 | msg = "Input values should either be a float between 0 and 1 or an int between 0 and 255" 62 | raise ValueError(msg) 63 | 64 | return '#{:02x}{:02x}{:02x}'.format(*rgb) 65 | 66 | else: 67 | return color 68 | 69 | 70 | def convert_colors_to_hex(graph: nx.Graph, color: str = 'color') -> None: 71 | """Convert all color labels in the graph to hexadecimal format. 72 | 73 | Args: 74 | graph (nx.Graph): The input graph with node attributes. 75 | color (str): The attribute name for the color. Default is 'color'. 76 | 77 | Returns: 78 | None: The function modifies the graph in place. 79 | 80 | .. note:: 81 | This function assumes that the color attribute is present in the node data. 82 | """ 83 | 84 | color_values = {node: color_hex(data[color]) for node, data in graph.nodes(data=True) if color in data} 85 | nx.set_node_attributes(graph, values=color_values, name=color) 86 | -------------------------------------------------------------------------------- /src/graphinate/constants.py: -------------------------------------------------------------------------------- 1 | DEFAULT_NODE_DELIMITER = ' ∋ ' 2 | DEFAULT_EDGE_DELIMITER = ' ⟹ ' 3 | 4 | __all__ = ['DEFAULT_EDGE_DELIMITER', 'DEFAULT_NODE_DELIMITER'] 5 | -------------------------------------------------------------------------------- /src/graphinate/converters.py: -------------------------------------------------------------------------------- 1 | import ast 2 | import base64 3 | import decimal 4 | import math 5 | from types import MappingProxyType 6 | from typing import Any, NewType, Union 7 | 8 | import strawberry 9 | 10 | from .constants import DEFAULT_EDGE_DELIMITER, DEFAULT_NODE_DELIMITER 11 | 12 | __all__ = [ 13 | 'InfNumber', 14 | 'decode_edge_id', 15 | 'decode_id', 16 | 'edge_label_converter', 17 | 'encode_edge_id', 18 | 'encode_id', 19 | 'infnum_to_value', 20 | 'label_converter', 21 | 'node_label_converter', 22 | 'value_to_infnum', 23 | ] 24 | 25 | InfNumber = NewType("InfNumber", Union[float, int, decimal.Decimal]) 26 | 27 | INFINITY_MAPPING = MappingProxyType({ 28 | 'Infinity': math.inf, 29 | '+Infinity': math.inf, 30 | '-Infinity': -math.inf 31 | }) 32 | 33 | MATH_INF_MAPPING = MappingProxyType({ 34 | math.inf: 'Infinity', 35 | -math.inf: '-Infinity' 36 | }) 37 | 38 | 39 | def value_to_infnum(value: any) -> InfNumber: 40 | return INFINITY_MAPPING.get(value, value) 41 | 42 | 43 | def infnum_to_value(value: InfNumber): 44 | return MATH_INF_MAPPING.get(value, value) 45 | 46 | 47 | def label_converter(value, delimiter: str): 48 | if value: 49 | return delimiter.join(str(v) for v in value) if isinstance(value, tuple) else str(value) 50 | 51 | return value 52 | 53 | 54 | def node_label_converter(value): 55 | return label_converter(value, delimiter=DEFAULT_NODE_DELIMITER) 56 | 57 | 58 | def edge_label_converter(value): 59 | return label_converter(tuple(node_label_converter(n) for n in value), delimiter=DEFAULT_EDGE_DELIMITER) 60 | 61 | 62 | def encode(value: Any, encoding: str = 'utf-8') -> str: 63 | obj_s: str = repr(value) 64 | obj_b: bytes = obj_s.encode(encoding) 65 | enc_b: bytes = base64.urlsafe_b64encode(obj_b) 66 | enc_s: str = enc_b.decode(encoding) 67 | return enc_s 68 | 69 | 70 | def decode(value: str, encoding: str = 'utf-8') -> Any: 71 | enc_b: bytes = value.encode(encoding) 72 | obj_b: bytes = base64.urlsafe_b64decode(enc_b) 73 | obj_s: str = obj_b.decode(encoding) 74 | obj: Any = ast.literal_eval(obj_s) 75 | return obj 76 | 77 | 78 | def encode_id(graph_node_id: tuple, 79 | encoding: str = 'utf-8') -> str: 80 | return encode(graph_node_id, encoding) 81 | 82 | 83 | def decode_id(graphql_node_id: strawberry.ID, 84 | encoding: str = 'utf-8') -> tuple[str, ...]: 85 | return decode(graphql_node_id, encoding) 86 | 87 | 88 | def encode_edge_id(edge: tuple, encoding: str = 'utf-8'): 89 | encoded_edge = tuple(encode_id(n, encoding) for n in edge) 90 | return encode_id(encoded_edge, encoding) 91 | 92 | 93 | def decode_edge_id(graphql_edge_id: strawberry.ID, encoding: str = 'utf-8'): 94 | encoded_edge: tuple = decode_id(graphql_edge_id, encoding) 95 | return tuple(decode_id(enc_node) for enc_node in encoded_edge) 96 | -------------------------------------------------------------------------------- /src/graphinate/modeling.py: -------------------------------------------------------------------------------- 1 | import inspect 2 | import itertools 3 | from collections import defaultdict, namedtuple 4 | from collections.abc import Callable, Iterable, Mapping 5 | from dataclasses import dataclass 6 | from enum import Enum, auto 7 | from typing import Any, Optional, Union 8 | 9 | from .typing import Edge, Element, Extractor, Items, Node, NodeTypeAbsoluteId, UniverseNode 10 | 11 | 12 | class GraphModelError(Exception): 13 | pass 14 | 15 | 16 | def element(element_type: Optional[str], field_names: Optional[Iterable[str]] = None) -> Callable[[...], Element]: 17 | """Graph Element Supplier Callable 18 | 19 | Args: 20 | element_type: 21 | field_names: 22 | 23 | Returns: 24 | Element Supplier Callable 25 | """ 26 | return namedtuple(element_type, field_names) if element_type and field_names else tuple 27 | 28 | 29 | def extractor(obj: Any, key: Optional[Extractor] = None) -> Optional[str]: 30 | """Extract data item from Element 31 | 32 | Args: 33 | obj: 34 | key: 35 | 36 | Returns: 37 | Element data item 38 | """ 39 | if key is None: 40 | return obj 41 | 42 | if callable(key): 43 | return key(obj) 44 | 45 | if isinstance(obj, Mapping) and isinstance(key, str): 46 | return obj.get(key, key) 47 | 48 | return key 49 | 50 | 51 | def elements(iterable: Iterable[Any], 52 | element_type: Optional[Extractor] = None, 53 | **getters: Extractor) -> Iterable[Element]: 54 | """Abstract Generator of Graph elements (nodes or edges) 55 | 56 | Args: 57 | iterable: source of payload 58 | element_type: Optional[Extractor] source of type of the element. Defaults to Element Type name. 59 | getters: Extractor node field sources 60 | 61 | Returns: 62 | Iterable of Elements. 63 | """ 64 | for item in iterable: 65 | _type = element_type(item) if element_type and callable(element_type) else element_type 66 | if not _type.isidentifier(): 67 | raise ValueError(f"Invalid Type: {_type}. Must be a valid Python identifier.") 68 | 69 | create_element = element(_type, getters.keys()) 70 | kwargs = {k: extractor(item, v) for k, v in getters.items()} 71 | yield create_element(**kwargs) 72 | 73 | 74 | class Multiplicity(Enum): 75 | ADD = auto() 76 | ALL = auto() 77 | FIRST = auto() 78 | LAST = auto() 79 | 80 | 81 | @dataclass 82 | class NodeModel: 83 | """Represents a Node Model 84 | 85 | Args: 86 | type: the type of the Node. 87 | parent_type: the type of the node's parent. Defaults to UniverseNode. 88 | parameters: parameters of the Node. Defaults to None. 89 | label: label source. Defaults to None. 90 | uniqueness: is the Node universally unique. Defaults to True. 91 | multiplicity: Multiplicity of the Node. Defaults to ALL. 92 | generator: Nodes generator method. Defaults to None. 93 | 94 | Properties: 95 | absolute_id: return the NodeModel absolute_id. 96 | """ 97 | 98 | type: str 99 | parent_type: Optional[str] = UniverseNode 100 | parameters: set[str] | None = None 101 | label: Callable[[Any], str | None] = None 102 | uniqueness: bool = True 103 | multiplicity: Multiplicity = Multiplicity.ALL 104 | generator: Callable[[], Iterable[Node]] | None = None 105 | 106 | @property 107 | def absolute_id(self) -> NodeTypeAbsoluteId: 108 | return self.parent_type, self.type 109 | 110 | 111 | class GraphModel: 112 | """A Graph Model 113 | 114 | Used to declaratively register Edge and/or Node data supplier functions by using 115 | decorators. 116 | 117 | Args: 118 | name: the archetype name for Graphs generated based on the GraphModel. 119 | """ 120 | 121 | def __init__(self, name: str): 122 | self.name: str = name 123 | self._node_models: dict[NodeTypeAbsoluteId, list[NodeModel]] = defaultdict(list) 124 | self._node_children: dict[str, list[str]] = defaultdict(list) 125 | self._edge_generators: dict[str, list[Callable[[], Iterable[Edge]]]] = defaultdict(list) 126 | self._networkx_graph = None 127 | 128 | def __add__(self, other: 'GraphModel'): 129 | graph_model = GraphModel(name=f"{self.name} + {other.name}") 130 | for m in (self, other): 131 | for k, v in m._node_models.items(): 132 | graph_model._node_models[k].extend(v) 133 | 134 | for k, v in m._node_children.items(): 135 | graph_model._node_children[k].extend(v) 136 | 137 | for k, v in m._edge_generators.items(): 138 | graph_model._edge_generators[k].extend(v) 139 | 140 | return graph_model 141 | 142 | @property 143 | def node_models(self) -> dict[NodeTypeAbsoluteId, list[NodeModel]]: 144 | """ 145 | Returns: 146 | NodeModel for Node Types. Key values are NodeTypeAbsoluteId. 147 | """ 148 | return self._node_models 149 | 150 | @property 151 | def edge_generators(self): 152 | """ 153 | Returns: 154 | Edge generator functions for Edge Types 155 | """ 156 | return self._edge_generators 157 | 158 | @property 159 | def node_types(self) -> set[str]: 160 | """ 161 | Returns: 162 | Node Types 163 | """ 164 | return {v.type for v in itertools.chain.from_iterable(self._node_models.values())} 165 | 166 | def node_children_types(self, _type: str = UniverseNode) -> dict[str, list[str]]: 167 | """Children Node Types for given input Node Type 168 | 169 | Args: 170 | _type: Node Type. Default value is UNIVERSE_NODE. 171 | 172 | Returns: 173 | List of children Node Types. 174 | """ 175 | return {k: v for k, v in self._node_children.items() if k == _type} 176 | 177 | @staticmethod 178 | def _validate_type(node_type: str): 179 | if not callable(node_type) and not node_type.isidentifier(): 180 | raise ValueError(f"Invalid Type: {node_type}. Must be a valid Python identifier.") 181 | 182 | def _validate_node_parameters(self, parameters: list[str]): 183 | node_types = self.node_types 184 | if not all(p.endswith('_id') and p == p.lower() and p[:-3] in node_types for p in parameters): 185 | msg = ("Illegal Arguments. Argument should conform to the following rules: " 186 | "1) lowercase " 187 | "2) end with '_id' " 188 | "3) start with value that exists as registered node type") 189 | 190 | raise GraphModelError(msg) 191 | 192 | def node(self, 193 | type_: Optional[Extractor] = None, 194 | parent_type: Optional[str] = UniverseNode, 195 | key: Optional[Extractor] = None, 196 | value: Optional[Extractor] = None, 197 | label: Optional[Extractor] = None, 198 | unique: bool = True, 199 | multiplicity: Multiplicity = Multiplicity.ALL) -> Callable[[Items], None]: 200 | """Decorator to Register a Generator of node payloads as a source for Graph Nodes. 201 | It creates a NodeModel object. 202 | 203 | Args: 204 | type_: Optional source for the Node Type. Defaults to use Generator function 205 | name as the Node Type. 206 | parent_type: Optional parent Node Type. Defaults to UNIVERSE_NODE 207 | 208 | key: Optional source for Node IDs. Defaults to use the complete Node payload 209 | as Node ID. 210 | value: Optional source for Node value field. Defaults to use the complete 211 | Node payload as Node ID. 212 | label: Optional source for Node label field. Defaults to use a 'str' 213 | representation of the complete Node payload. 214 | unique: is the Node universally unique. Defaults to True. 215 | multiplicity: Multiplicity of the Node. Defaults to ALL. 216 | 217 | Returns: 218 | None 219 | """ 220 | 221 | def register_node(f: Items): 222 | node_type = type_ or f.__name__ 223 | self._validate_type(node_type) 224 | 225 | model_type = f.__name__ if callable(node_type) else node_type 226 | 227 | def node_generator(**kwargs) -> Iterable[Node]: 228 | yield from elements(f(**kwargs), node_type, key=key, value=value) 229 | 230 | parameters = inspect.getfullargspec(f).args 231 | node_model = NodeModel(type=model_type, 232 | parent_type=parent_type, 233 | parameters=set(parameters), 234 | label=label, 235 | uniqueness=unique, 236 | multiplicity=multiplicity, 237 | generator=node_generator) 238 | self._node_models[node_model.absolute_id].append(node_model) 239 | self._node_children[parent_type].append(model_type) 240 | 241 | self._validate_node_parameters(parameters) 242 | 243 | return register_node 244 | 245 | def edge(self, 246 | type_: Optional[Extractor] = None, 247 | source: Extractor = 'source', 248 | target: Extractor = 'target', 249 | label: Optional[Extractor] = str, 250 | value: Optional[Extractor] = None, 251 | weight: Union[float, Callable[[Any], float]] = 1.0, 252 | ) -> Callable[[Items], None]: 253 | """Decorator to Register a generator of edge payloads as a source of Graph Edges. 254 | It creates an Edge generator function. 255 | 256 | Args: 257 | type_: Optional source for the Edge Type. Defaults to use Generator function 258 | name as the Edge Type. 259 | source: Source for edge source Node ID. 260 | target: Source for edge target Node ID. 261 | label: Source for edge label. 262 | value: Source for edge value. 263 | weight: Source for edge weight. 264 | 265 | Returns: 266 | None. 267 | """ 268 | 269 | def register_edge(f: Items): 270 | edge_type = type_ or f.__name__ 271 | self._validate_type(edge_type) 272 | 273 | model_type = f.__name__ if callable(edge_type) else edge_type 274 | 275 | getters = { 276 | 'source': source, 277 | 'target': target, 278 | 'label': label, 279 | 'type': edge_type, 280 | 'value': value, 281 | 'weight': weight 282 | } 283 | 284 | def edge_generator(**kwargs) -> Iterable[Edge]: 285 | yield from elements(f(**kwargs), edge_type, **getters) 286 | 287 | self._edge_generators[model_type].append(edge_generator) 288 | 289 | return register_edge 290 | 291 | def rectify(self, _type: Optional[Extractor] = None, 292 | parent_type: Optional[str] = UniverseNode, 293 | key: Optional[Extractor] = None, 294 | value: Optional[Extractor] = None, 295 | label: Optional[Extractor] = None): 296 | """Rectify the model. 297 | Add a default NodeModel in case of having just edge supplier/s and no node supplier/s. 298 | 299 | Args: 300 | _type 301 | parent_type 302 | key 303 | value 304 | label 305 | 306 | Returns: 307 | None 308 | """ 309 | if self._edge_generators and not self._node_models: 310 | @self.node( 311 | type_=_type or 'node', 312 | parent_type=parent_type or 'node', 313 | unique=True, 314 | key=key, 315 | value=value, 316 | label=label or str 317 | ) 318 | def node(): # pragma: no cover 319 | return 320 | yield 321 | 322 | 323 | def model(name: str): 324 | """Create a graph model 325 | 326 | Args: 327 | name: model name 328 | 329 | Returns: 330 | GraphModel 331 | """ 332 | return GraphModel(name=name) 333 | 334 | 335 | __all__ = ('GraphModel', 'Multiplicity', 'model') 336 | -------------------------------------------------------------------------------- /src/graphinate/renderers/__init__.py: -------------------------------------------------------------------------------- 1 | import networkx_mermaid.formatters as mermaid 2 | 3 | from . import graphql, matplotlib 4 | 5 | __all__ = ('graphql', 'matplotlib', 'mermaid') 6 | -------------------------------------------------------------------------------- /src/graphinate/renderers/graphql.py: -------------------------------------------------------------------------------- 1 | import contextlib 2 | import webbrowser 3 | 4 | import strawberry 5 | from starlette.applications import Starlette 6 | from starlette.requests import Request 7 | from starlette.responses import RedirectResponse 8 | from starlette.schemas import SchemaGenerator 9 | from starlette.types import ASGIApp 10 | from strawberry.asgi import GraphQL 11 | from strawberry.extensions.tracing import OpenTelemetryExtension 12 | 13 | from graphinate.server.starlette import routes 14 | 15 | DEFAULT_PORT: int = 8072 16 | 17 | GRAPHQL_ROUTE_PATH = "/graphql" 18 | 19 | 20 | def _openapi_schema(request: Request) -> ASGIApp: 21 | """ 22 | Generates an OpenAPI schema for the GraphQL API and other routes. 23 | 24 | Args: 25 | request (Request): The HTTP request object. 26 | 27 | Returns: 28 | ASGIApp: An OpenAPI response containing the schema for the specified routes. 29 | """ 30 | schema_data = { 31 | 'openapi': '3.0.0', 32 | 'info': {'title': 'Graphinate API', 'version': '0.8.5'}, 33 | 'paths': { 34 | '/graphql': {'get': {'responses': {200: {'description': 'GraphQL'}}}}, 35 | '/graphiql': {'get': {'responses': {200: {'description': 'GraphiQL UI.'}}}}, 36 | '/metrics': {'get': {'responses': {200: {'description': 'Prometheus metrics.'}}}}, 37 | '/viewer': {'get': {'responses': {200: {'description': '3D Force-Directed Graph Viewer'}}}}, 38 | '/voyager': {'get': {'responses': {200: {'description': 'Voyager GraphQL Schema Viewer'}}}} 39 | } 40 | } 41 | 42 | schema = SchemaGenerator(schema_data) 43 | return schema.OpenAPIResponse(request=request) 44 | 45 | 46 | def _graphql_app(graphql_schema: strawberry.Schema) -> strawberry.asgi.GraphQL: 47 | graphql_schema.extensions.append(OpenTelemetryExtension) 48 | graphql_app = GraphQL(graphql_schema, graphiql=True) 49 | return graphql_app 50 | 51 | 52 | def _starlette_app(graphql_app: strawberry.asgi.GraphQL | None = None, port: int = DEFAULT_PORT, **kwargs) -> Starlette: 53 | def open_url(endpoint): 54 | webbrowser.open(f'http://localhost:{port}/{endpoint}') 55 | 56 | @contextlib.asynccontextmanager 57 | async def lifespan(app: Starlette): # pragma: no cover 58 | if kwargs.get('browse'): 59 | open_url('viewer') 60 | yield 61 | 62 | app = Starlette( 63 | lifespan=lifespan, 64 | routes=routes() 65 | ) 66 | 67 | from starlette_prometheus import PrometheusMiddleware, metrics 68 | app.add_middleware(PrometheusMiddleware) 69 | app.add_route("/metrics", metrics) 70 | 71 | if graphql_app: 72 | app.add_route(GRAPHQL_ROUTE_PATH, graphql_app) 73 | app.add_websocket_route(GRAPHQL_ROUTE_PATH, graphql_app) 74 | app.add_route("/schema", route=_openapi_schema, include_in_schema=False) 75 | app.add_route("/openapi.json", route=_openapi_schema, include_in_schema=False) 76 | 77 | async def redirect_to_viewer(request): 78 | return RedirectResponse(url='/viewer') 79 | 80 | app.add_route('/', redirect_to_viewer) 81 | 82 | return app 83 | 84 | 85 | def server(graphql_schema: strawberry.Schema, port: int = DEFAULT_PORT, **kwargs): 86 | """ 87 | Args: 88 | graphql_schema: The Strawberry GraphQL schema. 89 | port: The port number to run the server on. Defaults to 8072. 90 | 91 | Returns: 92 | """ 93 | 94 | graphql_app = _graphql_app(graphql_schema) 95 | 96 | app = _starlette_app(graphql_app, port=port, **kwargs) 97 | 98 | import uvicorn 99 | uvicorn.run(app, host='0.0.0.0', port=port) 100 | 101 | 102 | __all__ = ['server'] 103 | -------------------------------------------------------------------------------- /src/graphinate/renderers/matplotlib.py: -------------------------------------------------------------------------------- 1 | import networkx as nx 2 | from matplotlib import pyplot 3 | 4 | from ..color import node_color_mapping 5 | 6 | 7 | def draw(graph: nx.Graph, 8 | with_node_labels: bool = True, 9 | with_edge_labels: bool = False, 10 | **kwargs): 11 | """ 12 | Draws the given networkx graph with optional node and edge labels. 13 | 14 | Args: 15 | graph (nx.Graph): The input graph to be drawn. 16 | with_node_labels (bool): Whether to display node labels. Default is True. 17 | with_edge_labels (bool): Whether to display edge labels. Default is False. 18 | 19 | Returns: 20 | None 21 | """ 22 | pos = nx.planar_layout(graph) if nx.is_planar(graph) else None 23 | pos = nx.spring_layout(graph, pos=pos) if pos else nx.spring_layout(graph) 24 | 25 | draw_params = {} 26 | if with_node_labels: 27 | draw_params.update( 28 | { 29 | 'with_labels': True, 30 | 'labels': nx.get_node_attributes(graph, 'label'), 31 | 'font_size': 6, 32 | 'font_color': 'blue', 33 | # 'horizontalalignment':'left', 34 | # 'verticalalignment':'bottom', 35 | # 'bbox': { 36 | # 'boxstyle': 'round', 37 | # 'fc': (0.02, 0.02, 0.02), 38 | # 'lw': 0, 39 | # 'alpha': 0.15, 40 | # 'path_effects': [patheffects.withStroke(linewidth=1, foreground="red")] 41 | # } 42 | } 43 | ) 44 | 45 | node_color = list(node_color_mapping(graph).values()) 46 | nx.draw(graph, pos, node_color=node_color, **draw_params) 47 | if with_edge_labels: 48 | nx.draw_networkx_edge_labels(graph, 49 | pos, 50 | edge_labels=nx.get_edge_attributes(graph, 'label'), 51 | font_color='red', 52 | font_size=6) 53 | 54 | 55 | def plot(graph: nx.Graph, 56 | with_node_labels: bool = True, 57 | with_edge_labels: bool = False, 58 | **kwargs): 59 | """ 60 | Plots the given networkx graph with optional node and edge labels. 61 | 62 | Args: 63 | graph (nx.Graph): The input graph to be plotted. 64 | with_node_labels (bool): Whether to display node labels. Default is True. 65 | with_edge_labels (bool): Whether to display edge labels. Default is False. 66 | 67 | Returns: 68 | None 69 | """ 70 | draw(graph, with_node_labels, with_edge_labels, **kwargs) 71 | 72 | ax = pyplot.gca() 73 | ax.margins(0.10) 74 | 75 | fig = pyplot.gcf() 76 | fig.suptitle(graph.name) 77 | fig.tight_layout() 78 | 79 | # pyplot.axis("off") 80 | pyplot.show() 81 | -------------------------------------------------------------------------------- /src/graphinate/server/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/erivlis/graphinate/f96201253aa713e78342f075ef990094e70bd698/src/graphinate/server/__init__.py -------------------------------------------------------------------------------- /src/graphinate/server/starlette/__init__.py: -------------------------------------------------------------------------------- 1 | from collections.abc import Mapping 2 | from pathlib import Path 3 | 4 | from starlette.routing import Mount 5 | from starlette.staticfiles import StaticFiles 6 | 7 | from ..web import paths_mapping 8 | 9 | 10 | def _mount_static_files(named_paths: Mapping[str, Path]) -> list[Mount]: 11 | mounts = [] 12 | for name, path in named_paths.items(): 13 | if not name.startswith('__'): 14 | index_file = path / 'index.html' 15 | static_files = StaticFiles(directory=path, html=index_file.exists(), check_dir=True) 16 | mount = Mount(path=f"/{name}", app=static_files, name=name) 17 | mounts.append(mount) 18 | return mounts 19 | 20 | 21 | def routes(): 22 | route_list = _mount_static_files(paths_mapping) 23 | 24 | from .views import favicon_route 25 | route_list.append(favicon_route()) 26 | 27 | return route_list 28 | 29 | 30 | __all__ = ('routes',) 31 | -------------------------------------------------------------------------------- /src/graphinate/server/starlette/views.py: -------------------------------------------------------------------------------- 1 | from starlette.responses import FileResponse 2 | from starlette.routing import Route 3 | 4 | from ..web import get_static_path 5 | 6 | 7 | async def favicon(request): 8 | path = get_static_path('images/logo-128.png').absolute().as_posix() 9 | return FileResponse(path) 10 | 11 | 12 | def favicon_route() -> Route: 13 | return Route('/favicon.ico', endpoint=favicon, include_in_schema=False) 14 | -------------------------------------------------------------------------------- /src/graphinate/server/web/__init__.py: -------------------------------------------------------------------------------- 1 | import inspect 2 | import pathlib 3 | from collections.abc import Mapping 4 | 5 | 6 | def current_file() -> pathlib.Path: 7 | """Returns current file name""" 8 | return pathlib.Path(inspect.getfile(inspect.currentframe().f_back)) 9 | 10 | 11 | paths_mapping: Mapping[str, pathlib.Path] = {p.name: p for p in current_file().parent.iterdir() if p.is_dir()} 12 | 13 | 14 | def get_static_path(relative_path: str) -> pathlib.Path: 15 | return paths_mapping['static'] / relative_path 16 | -------------------------------------------------------------------------------- /src/graphinate/server/web/elements/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/erivlis/graphinate/f96201253aa713e78342f075ef990094e70bd698/src/graphinate/server/web/elements/__init__.py -------------------------------------------------------------------------------- /src/graphinate/server/web/elements/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | Graphinate - OpanAPI Elements UI 8 | 9 | 11 | 13 | 14 | 15 | 16 | 21 | 22 | 23 | -------------------------------------------------------------------------------- /src/graphinate/server/web/graphiql/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/erivlis/graphinate/f96201253aa713e78342f075ef990094e70bd698/src/graphinate/server/web/graphiql/__init__.py -------------------------------------------------------------------------------- /src/graphinate/server/web/graphiql/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Graphinate - GraphiQL 5 | 35 | 36 | 41 | 46 | 47 | 52 | 53 | 59 | 60 | 66 | 67 | 68 | 69 |
Loading...
70 | 75 | 80 | 159 | 160 | 161 | -------------------------------------------------------------------------------- /src/graphinate/server/web/rapidoc/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/erivlis/graphinate/f96201253aa713e78342f075ef990094e70bd698/src/graphinate/server/web/rapidoc/__init__.py -------------------------------------------------------------------------------- /src/graphinate/server/web/rapidoc/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 8 | Graphinate - OpenAPI RapiDoc UI 9 | 10 | 11 | 16 | 17 | -------------------------------------------------------------------------------- /src/graphinate/server/web/static/images/logo-128.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/erivlis/graphinate/f96201253aa713e78342f075ef990094e70bd698/src/graphinate/server/web/static/images/logo-128.png -------------------------------------------------------------------------------- /src/graphinate/server/web/static/images/logo.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 𝔾raphinate 20 | 21 | 22 | 23 | 24 | 25 | 27 | 28 | 37 | 𝔾 38 | 39 | 40 | 41 | 𝔾raphinate 49 | 50 | 51 | -------------------------------------------------------------------------------- /src/graphinate/server/web/static/images/network_graph.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/erivlis/graphinate/f96201253aa713e78342f075ef990094e70bd698/src/graphinate/server/web/static/images/network_graph.png -------------------------------------------------------------------------------- /src/graphinate/server/web/viewer/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/erivlis/graphinate/f96201253aa713e78342f075ef990094e70bd698/src/graphinate/server/web/viewer/__init__.py -------------------------------------------------------------------------------- /src/graphinate/server/web/voyager/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/erivlis/graphinate/f96201253aa713e78342f075ef990094e70bd698/src/graphinate/server/web/voyager/__init__.py -------------------------------------------------------------------------------- /src/graphinate/server/web/voyager/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Graphinate - GraphQL Voyager 5 | 16 | 17 | 22 | 25 | 28 | 29 | 30 |
Loading...
31 | 52 | 53 | -------------------------------------------------------------------------------- /src/graphinate/tools.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime, timezone 2 | 3 | UTC = timezone.utc 4 | 5 | 6 | def utcnow() -> datetime: 7 | return datetime.now(tz=UTC) 8 | -------------------------------------------------------------------------------- /src/graphinate/typing.py: -------------------------------------------------------------------------------- 1 | """Typing Module 2 | 3 | Attributes: 4 | Node (Node): Node Type 5 | Edge (Edge): Edge Type 6 | Element (Element): Element Type 7 | Extractor (Extractor): Source of data for an Element 8 | """ 9 | 10 | from collections.abc import Callable, Iterable 11 | from typing import Any, NamedTuple, NewType, Protocol, TypeVar, Union 12 | 13 | IdentifierStr = NewType('IdentifierStr', str) 14 | IdentifierStr.__doc__ = "A string that is a valid Python identifier (i.e., `isidentifier()` is True)." 15 | 16 | NodeTypeAbsoluteId = NewType("NodeTypeAbsoluteId", tuple[str, str]) 17 | NodeTypeAbsoluteId.__doc__ = "A unique identifier for a node type." 18 | 19 | UniverseNode = NewType('UniverseNode', None) 20 | UniverseNode.__doc__ = "The UniverseNode Type. All Node Types are the implicit children of the Universe Node Type." 21 | 22 | Node = Union[type[NamedTuple], tuple[str, Any]] # noqa: UP007 23 | Node.__doc__ = "A node in a graph." 24 | 25 | Edge = Union[type[NamedTuple], tuple[str, str, Any]] # noqa: UP007 26 | Edge.__doc__ = "An edge in a graph." 27 | 28 | Element = Union[Node, Edge] # noqa: UP007 29 | Element.__doc__ = "An element in a graph." 30 | 31 | Extractor = Union[str, Callable[[Any], str]] # noqa: UP007 32 | Extractor.__doc__ = "A source of data for an element." 33 | 34 | T = TypeVar("T") 35 | 36 | 37 | class Items(Protocol): 38 | def __call__(self, **kwargs) -> Iterable[T]: 39 | ... # pragma: no cover 40 | 41 | 42 | class Nodes(Protocol): 43 | def __call__(self, **kwargs) -> Iterable[Node]: 44 | ... # pragma: no cover 45 | 46 | 47 | class Edges(Protocol): 48 | def __call__(self, **kwargs) -> Iterable[Edge]: 49 | ... # pragma: no cover 50 | 51 | 52 | class Predicate(Protocol): 53 | def __call__(self, **kwargs) -> bool: 54 | ... # pragma: no cover 55 | 56 | 57 | class Supplier(Protocol): 58 | def __call__(self) -> Any: 59 | ... # pragma: no cover 60 | 61 | # ParametersId = frozenset 62 | 63 | 64 | # def parameters_id(mapping: Mapping) -> ParametersId: 65 | # return frozenset(mapping.items()) 66 | -------------------------------------------------------------------------------- /tests/conftest.py: -------------------------------------------------------------------------------- 1 | import ast 2 | import hashlib 3 | import inspect 4 | import operator 5 | import pickle 6 | import random 7 | from _ast import AST 8 | from collections.abc import Iterable 9 | 10 | import faker 11 | import pytest 12 | 13 | import graphinate 14 | 15 | 16 | @pytest.fixture 17 | def country_count(): 18 | return random.randint(1, 10) 19 | 20 | 21 | @pytest.fixture 22 | def city_count(): 23 | return random.randint(20, 40) 24 | 25 | 26 | def _ast_nodes(parsed_asts: Iterable[AST]): 27 | for item in parsed_asts: 28 | if not isinstance(item, ast.Load): 29 | yield item 30 | yield from _ast_nodes(ast.iter_child_nodes(item)) 31 | 32 | 33 | def _ast_edge(parsed_ast: AST): 34 | for child_ast in ast.iter_child_nodes(parsed_ast): 35 | if not isinstance(child_ast, ast.Load): 36 | edge = {'source': parsed_ast, 'target': child_ast} 37 | edge_types = (field_name for field_name, value in ast.iter_fields(parsed_ast) if 38 | child_ast == value or (child_ast in value if isinstance(value, list) else False)) 39 | edge_type = next(edge_types, None) 40 | if edge_type: 41 | edge['type'] = edge_type 42 | yield edge 43 | yield from _ast_edge(child_ast) 44 | 45 | 46 | @pytest.fixture 47 | def ast_graph_model(): 48 | graph_model = graphinate.model(name='AST Graph') 49 | 50 | root_ast_node = ast.parse(inspect.getsource(graphinate.builders.D3Builder)) 51 | 52 | def node_type(ast_node): 53 | return ast_node.__class__.__name__ 54 | 55 | def node_label(ast_node) -> str: 56 | label = ast_node.__class__.__name__ 57 | 58 | for field_name in ('name', 'id'): 59 | if field_name in ast_node._fields: 60 | label = f"{label}\n{field_name}: {operator.attrgetter(field_name)(ast_node)}" 61 | 62 | return label 63 | 64 | def key(value): 65 | # noinspection InsecureHash 66 | return hashlib.shake_128(pickle.dumps(value)).hexdigest(20) 67 | 68 | def endpoint(value, endpoint_name): 69 | return key(value[endpoint_name]) 70 | 71 | def source(value): 72 | return endpoint(value, 'source') 73 | 74 | def target(value): 75 | return endpoint(value, 'target') 76 | 77 | @graph_model.node(type_=node_type, key=key, label=node_label, unique=True) 78 | def ast_node(**kwargs): 79 | yield from _ast_nodes([root_ast_node]) 80 | 81 | @graph_model.edge(type_='edge', source=source, target=target, label=operator.itemgetter('type')) 82 | def ast_edge(**kwargs): 83 | yield from _ast_edge(root_ast_node) 84 | 85 | return graph_model 86 | 87 | 88 | @pytest.fixture 89 | def map_graph_model(country_count, city_count): 90 | country_ids = {str(c): None for c in range(1, country_count + 1)} 91 | city_ids = {str(c): random.choice(list(country_ids.keys())) for c in range(1, city_count + 1)} 92 | 93 | graph_model = graphinate.model(name='Map') 94 | 95 | faker.Faker.seed(0) 96 | fake = faker.Faker() 97 | 98 | def country_node_label(value): 99 | return fake.country() 100 | 101 | def city_node_label(value): 102 | return fake.city() 103 | 104 | @graph_model.node(label=country_node_label, unique=False) 105 | def country(country_id=None, **kwargs): 106 | 107 | if country_id and country_id in country_ids: 108 | yield country_id 109 | else: 110 | yield from country_ids 111 | 112 | @graph_model.node(parent_type='country', label=city_node_label, unique=False) 113 | def city(country_id=None, city_id=None, **kwargs): 114 | 115 | if country_id is None and city_id is None: 116 | yield from city_ids.keys() 117 | 118 | if country_id is None and city_id is not None and city_id in city_ids: 119 | yield city_id 120 | 121 | if city_id is not None and country_id is not None and city_ids.get(city_id) == country_id: 122 | yield city_id 123 | 124 | if country_id is not None and city_id is None: 125 | yield from (k for k, v in city_ids.items() if v == country_id) 126 | 127 | @graph_model.node(type_=operator.itemgetter('sex'), 128 | parent_type='city', 129 | unique= False, 130 | key=operator.itemgetter('username'), 131 | label=operator.itemgetter('name')) 132 | def person(country_id=None, city_id=None, person_id=None, **kwargs): 133 | yield fake.profile() 134 | 135 | return country_count, city_count, graph_model 136 | 137 | 138 | @pytest.fixture 139 | def octagonal_graph_model(): 140 | graph_model = graphinate.model(name="Octagonal Graph") 141 | number_of_sides = 8 142 | 143 | # Register edges supplier function 144 | @graph_model.edge() 145 | def edge(): 146 | for i in range(number_of_sides): 147 | yield {'source': i, 'target': i + 1} 148 | yield {'source': number_of_sides, 'target': 0} 149 | 150 | return graph_model 151 | 152 | 153 | @pytest.fixture 154 | def graphql_query(): 155 | return """ 156 | query Graph { 157 | graph { 158 | name 159 | nodeTypeCounts { 160 | name 161 | value 162 | } 163 | edgeTypeCounts { 164 | name 165 | value 166 | } 167 | created 168 | nodeCount 169 | edgeCount 170 | size 171 | order 172 | radius 173 | diameter 174 | averageDegree 175 | hash 176 | } 177 | nodes { 178 | id 179 | ...ElementDetails 180 | neighbors {id type label} 181 | children: neighbors(children: true) {id type label} 182 | edges {id type label} 183 | } 184 | edges { 185 | source {id ...ElementDetails} 186 | target {id ...ElementDetails} 187 | ...ElementDetails 188 | weight 189 | } 190 | } 191 | 192 | fragment ElementDetails on GraphElement { 193 | label 194 | type 195 | label 196 | color 197 | created 198 | updated 199 | } 200 | """ 201 | -------------------------------------------------------------------------------- /tests/graphinate/renderers/test_matplotlib_draw.py: -------------------------------------------------------------------------------- 1 | import networkx as nx 2 | 3 | from graphinate.renderers.matplotlib import draw 4 | 5 | 6 | class TestDraw: 7 | 8 | # Drawing a graph with default parameters (node labels on, edge labels off) 9 | def test_draw_with_default_parameters(self, mocker): 10 | # Arrange 11 | mock_nx_draw = mocker.patch('networkx.draw') 12 | mock_nx_draw_edge_labels = mocker.patch('networkx.draw_networkx_edge_labels') 13 | mock_spring_layout = mocker.patch('networkx.spring_layout', return_value='mock_pos') 14 | mock_is_planar = mocker.patch('networkx.is_planar', return_value=False) 15 | mock_node_color_mapping = mocker.patch('graphinate.renderers.matplotlib.node_color_mapping', 16 | return_value={0: 'red', 1: 'blue'}) 17 | 18 | graph = nx.Graph() 19 | graph.add_node(0, label='Node0') 20 | graph.add_node(1, label='Node1') 21 | graph.add_edge(0, 1) 22 | 23 | # Act 24 | draw(graph) 25 | 26 | # Assert 27 | mock_is_planar.assert_called_once_with(graph) 28 | mock_spring_layout.assert_called_once_with(graph) 29 | mock_node_color_mapping.assert_called_once_with(graph) 30 | mock_nx_draw.assert_called_once() 31 | mock_nx_draw_edge_labels.assert_not_called() 32 | 33 | # Drawing a graph with node labels turned off 34 | def test_draw_with_node_labels_off(self, mocker): 35 | # Arrange 36 | mock_nx_draw = mocker.patch('networkx.draw') 37 | mock_spring_layout = mocker.patch('networkx.spring_layout', return_value='mock_pos') 38 | mock_is_planar = mocker.patch('networkx.is_planar', return_value=False) 39 | mock_node_color_mapping = mocker.patch('graphinate.renderers.matplotlib.node_color_mapping', 40 | return_value={0: 'red', 1: 'blue'}) 41 | 42 | graph = nx.Graph() 43 | graph.add_node(0, label='Node0') 44 | graph.add_node(1, label='Node1') 45 | graph.add_edge(0, 1) 46 | 47 | # Act 48 | draw(graph, with_node_labels=False) 49 | 50 | # Assert 51 | mock_is_planar.assert_called_once_with(graph) 52 | mock_spring_layout.assert_called_once_with(graph) 53 | mock_node_color_mapping.assert_called_once_with(graph) 54 | mock_nx_draw.assert_called_once() 55 | # Check that 'with_labels' is not in the draw parameters 56 | args, kwargs = mock_nx_draw.call_args 57 | assert 'with_labels' not in kwargs 58 | 59 | # Drawing a graph with edge labels turned on 60 | def test_draw_with_edge_labels_on(self, mocker): 61 | # Arrange 62 | mock_nx_draw = mocker.patch('networkx.draw') 63 | mock_nx_draw_edge_labels = mocker.patch('networkx.draw_networkx_edge_labels') 64 | mock_spring_layout = mocker.patch('networkx.spring_layout', return_value='mock_pos') 65 | mock_is_planar = mocker.patch('networkx.is_planar', return_value=False) 66 | mock_node_color_mapping = mocker.patch('graphinate.renderers.matplotlib.node_color_mapping', 67 | return_value={0: 'red', 1: 'blue'}) 68 | 69 | graph = nx.Graph() 70 | graph.add_node(0, label='Node0') 71 | graph.add_node(1, label='Node1') 72 | graph.add_edge(0, 1, label='Edge0-1') 73 | 74 | # Act 75 | draw(graph, with_edge_labels=True) 76 | 77 | # Assert 78 | mock_is_planar.assert_called_once_with(graph) 79 | mock_spring_layout.assert_called_once_with(graph) 80 | mock_node_color_mapping.assert_called_once_with(graph) 81 | mock_nx_draw.assert_called_once() 82 | mock_nx_draw_edge_labels.assert_called_once() 83 | 84 | # Drawing a graph with both node and edge labels turned on 85 | def test_draw_with_both_labels_on(self, mocker): 86 | # Arrange 87 | mock_nx_draw = mocker.patch('networkx.draw') 88 | mock_nx_draw_edge_labels = mocker.patch('networkx.draw_networkx_edge_labels') 89 | mock_spring_layout = mocker.patch('networkx.spring_layout', return_value='mock_pos') 90 | mock_is_planar = mocker.patch('networkx.is_planar', return_value=False) 91 | mock_node_color_mapping = mocker.patch('graphinate.renderers.matplotlib.node_color_mapping', 92 | return_value={0: 'red', 1: 'blue'}) 93 | 94 | graph = nx.Graph() 95 | graph.add_node(0, label='Node0') 96 | graph.add_node(1, label='Node1') 97 | graph.add_edge(0, 1, label='Edge0-1') 98 | 99 | # Act 100 | draw(graph, with_node_labels=True, with_edge_labels=True) 101 | 102 | # Assert 103 | mock_is_planar.assert_called_once_with(graph) 104 | mock_spring_layout.assert_called_once_with(graph) 105 | mock_node_color_mapping.assert_called_once_with(graph) 106 | mock_nx_draw.assert_called_once() 107 | mock_nx_draw_edge_labels.assert_called_once() 108 | 109 | # Check that node labels are enabled 110 | args, kwargs = mock_nx_draw.call_args 111 | assert kwargs.get('with_labels') is True 112 | 113 | # Drawing a planar graph uses planar_layout first 114 | def test_draw_planar_graph(self, mocker): 115 | # Arrange 116 | mock_nx_draw = mocker.patch('networkx.draw') 117 | mock_planar_layout = mocker.patch('networkx.planar_layout', return_value='planar_pos') 118 | mock_spring_layout = mocker.patch('networkx.spring_layout', return_value='spring_pos') 119 | mock_is_planar = mocker.patch('networkx.is_planar', return_value=True) 120 | mock_node_color_mapping = mocker.patch('graphinate.renderers.matplotlib.node_color_mapping', 121 | return_value={0: 'red', 1: 'blue'}) 122 | 123 | graph = nx.Graph() 124 | graph.add_node(0) 125 | graph.add_node(1) 126 | graph.add_edge(0, 1) 127 | 128 | # Act 129 | draw(graph) 130 | 131 | # Assert 132 | mock_is_planar.assert_called_once_with(graph) 133 | mock_planar_layout.assert_called_once_with(graph) 134 | mock_spring_layout.assert_called_once_with(graph, pos='planar_pos') 135 | mock_node_color_mapping.assert_called_once_with(graph) 136 | mock_nx_draw.assert_called_once() 137 | 138 | # Drawing a non-planar graph uses spring_layout directly 139 | def test_draw_non_planar_graph(self, mocker): 140 | # Arrange 141 | mock_nx_draw = mocker.patch('networkx.draw') 142 | mock_planar_layout = mocker.patch('networkx.planar_layout') 143 | mock_spring_layout = mocker.patch('networkx.spring_layout', return_value='spring_pos') 144 | mock_is_planar = mocker.patch('networkx.is_planar', return_value=False) 145 | mock_node_color_mapping = mocker.patch('graphinate.renderers.matplotlib.node_color_mapping', 146 | return_value={0: 'red', 1: 'blue'}) 147 | 148 | graph = nx.Graph() 149 | graph.add_node(0) 150 | graph.add_node(1) 151 | graph.add_edge(0, 1) 152 | 153 | # Act 154 | draw(graph) 155 | 156 | # Assert 157 | mock_is_planar.assert_called_once_with(graph) 158 | mock_planar_layout.assert_not_called() 159 | mock_spring_layout.assert_called_once_with(graph) 160 | mock_node_color_mapping.assert_called_once_with(graph) 161 | mock_nx_draw.assert_called_once() 162 | 163 | # Drawing an empty graph 164 | def test_draw_empty_graph(self, mocker): 165 | # Arrange 166 | mock_nx_draw = mocker.patch('networkx.draw') 167 | mock_spring_layout = mocker.patch('networkx.spring_layout', return_value={}) 168 | mock_is_planar = mocker.patch('networkx.is_planar', return_value=True) 169 | mock_node_color_mapping = mocker.patch('graphinate.renderers.matplotlib.node_color_mapping', 170 | return_value={}) 171 | 172 | graph = nx.Graph() 173 | 174 | # Act 175 | draw(graph) 176 | 177 | # Assert 178 | mock_is_planar.assert_called_once_with(graph) 179 | mock_spring_layout.assert_called_once() 180 | mock_node_color_mapping.assert_called_once_with(graph) 181 | mock_nx_draw.assert_called_once() 182 | 183 | # Check that node_color is an empty list for empty graph 184 | args, kwargs = mock_nx_draw.call_args 185 | assert kwargs.get('node_color') == [] 186 | 187 | # Drawing a graph with no node attributes for labels when with_node_labels=True 188 | def test_draw_no_node_labels_attribute(self, mocker): 189 | # Arrange 190 | mock_nx_draw = mocker.patch('networkx.draw') 191 | mock_get_node_attributes = mocker.patch('networkx.get_node_attributes', return_value={}) 192 | mock_spring_layout = mocker.patch('networkx.spring_layout', return_value='mock_pos') 193 | mock_is_planar = mocker.patch('networkx.is_planar', return_value=False) 194 | mock_node_color_mapping = mocker.patch('graphinate.renderers.matplotlib.node_color_mapping', 195 | return_value={0: 'red', 1: 'blue'}) 196 | 197 | graph = nx.Graph() 198 | graph.add_node(0) # No label attribute 199 | graph.add_node(1) # No label attribute 200 | graph.add_edge(0, 1) 201 | 202 | # Act 203 | draw(graph, with_node_labels=True) 204 | 205 | # Assert 206 | mock_is_planar.assert_called_once_with(graph) 207 | mock_spring_layout.assert_called_once_with(graph) 208 | mock_node_color_mapping.assert_called_once_with(graph) 209 | mock_get_node_attributes.assert_called_once_with(graph, 'label') 210 | mock_nx_draw.assert_called_once() 211 | 212 | # Check that labels parameter is empty dict 213 | args, kwargs = mock_nx_draw.call_args 214 | assert kwargs.get('labels') == {} 215 | 216 | # Drawing a graph with no edge attributes for labels when with_edge_labels=True 217 | def test_draw_no_edge_labels_attribute(self, mocker): 218 | # Arrange 219 | mock_nx_draw = mocker.patch('networkx.draw') 220 | mock_nx_draw_edge_labels = mocker.patch('networkx.draw_networkx_edge_labels') 221 | mock_get_edge_attributes = mocker.patch('networkx.get_edge_attributes', return_value={}) 222 | mock_spring_layout = mocker.patch('networkx.spring_layout', return_value='mock_pos') 223 | mock_is_planar = mocker.patch('networkx.is_planar', return_value=False) 224 | mock_node_color_mapping = mocker.patch('graphinate.renderers.matplotlib.node_color_mapping', 225 | return_value={0: 'red', 1: 'blue'}) 226 | 227 | graph = nx.Graph() 228 | graph.add_node(0) 229 | graph.add_node(1) 230 | graph.add_edge(0, 1) # No label attribute 231 | 232 | # Act 233 | draw(graph, with_edge_labels=True) 234 | 235 | # Assert 236 | mock_is_planar.assert_called_once_with(graph) 237 | mock_spring_layout.assert_called_once_with(graph) 238 | mock_node_color_mapping.assert_called_once_with(graph) 239 | mock_get_edge_attributes.assert_called_once_with(graph, 'label') 240 | mock_nx_draw.assert_called_once() 241 | mock_nx_draw_edge_labels.assert_called_once() 242 | 243 | # Check that edge_labels parameter is empty dict 244 | args, kwargs = mock_nx_draw_edge_labels.call_args 245 | assert kwargs.get('edge_labels') == {} 246 | -------------------------------------------------------------------------------- /tests/graphinate/renderers/test_matplotlib_plot.py: -------------------------------------------------------------------------------- 1 | import networkx as nx 2 | 3 | 4 | class TestPlot: 5 | 6 | # Plot a simple graph with default parameters (node labels shown, edge labels hidden) 7 | def test_plot_with_default_parameters(self, mocker): 8 | # Arrange 9 | mock_draw = mocker.patch('graphinate.renderers.matplotlib.draw') 10 | mock_pyplot = mocker.patch('graphinate.renderers.matplotlib.pyplot') 11 | mock_ax = mocker.MagicMock() 12 | mock_fig = mocker.MagicMock() 13 | mock_pyplot.gca.return_value = mock_ax 14 | mock_pyplot.gcf.return_value = mock_fig 15 | 16 | graph = nx.Graph(name="Test Graph") 17 | graph.add_node(1, label="Node 1") 18 | graph.add_edge(1, 2, label="Edge 1-2") 19 | 20 | # Act 21 | from graphinate.renderers.matplotlib import plot 22 | plot(graph) 23 | 24 | # Assert 25 | mock_draw.assert_called_once_with(graph, True, False) 26 | mock_ax.margins.assert_called_once_with(0.10) 27 | mock_fig.suptitle.assert_called_once_with("Test Graph") 28 | mock_fig.tight_layout.assert_called_once() 29 | mock_pyplot.show.assert_called_once() 30 | 31 | # Plot a graph with both node and edge labels displayed 32 | def test_plot_with_node_and_edge_labels(self, mocker): 33 | # Arrange 34 | mock_draw = mocker.patch('graphinate.renderers.matplotlib.draw') 35 | mock_pyplot = mocker.patch('graphinate.renderers.matplotlib.pyplot') 36 | mock_ax = mocker.MagicMock() 37 | mock_fig = mocker.MagicMock() 38 | mock_pyplot.gca.return_value = mock_ax 39 | mock_pyplot.gcf.return_value = mock_fig 40 | 41 | graph = nx.Graph(name="Test Graph") 42 | graph.add_node(1, label="Node 1") 43 | graph.add_edge(1, 2, label="Edge 1-2") 44 | 45 | # Act 46 | from graphinate.renderers.matplotlib import plot 47 | plot(graph, with_node_labels=True, with_edge_labels=True) 48 | 49 | # Assert 50 | mock_draw.assert_called_once_with(graph, True, True) 51 | mock_ax.margins.assert_called_once_with(0.10) 52 | mock_fig.suptitle.assert_called_once_with("Test Graph") 53 | mock_fig.tight_layout.assert_called_once() 54 | mock_pyplot.show.assert_called_once() 55 | 56 | # Plot a graph with neither node nor edge labels displayed 57 | def test_plot_without_node_and_edge_labels(self, mocker): 58 | # Arrange 59 | mock_draw = mocker.patch('graphinate.renderers.matplotlib.draw') 60 | mock_pyplot = mocker.patch('graphinate.renderers.matplotlib.pyplot') 61 | mock_ax = mocker.MagicMock() 62 | mock_fig = mocker.MagicMock() 63 | mock_pyplot.gca.return_value = mock_ax 64 | mock_pyplot.gcf.return_value = mock_fig 65 | 66 | graph = nx.Graph(name="Test Graph") 67 | graph.add_node(1, label="Node 1") 68 | graph.add_edge(1, 2, label="Edge 1-2") 69 | 70 | # Act 71 | from graphinate.renderers.matplotlib import plot 72 | plot(graph, with_node_labels=False, with_edge_labels=False) 73 | 74 | # Assert 75 | mock_draw.assert_called_once_with(graph, False, False) 76 | mock_ax.margins.assert_called_once_with(0.10) 77 | mock_fig.suptitle.assert_called_once_with("Test Graph") 78 | mock_fig.tight_layout.assert_called_once() 79 | mock_pyplot.show.assert_called_once() 80 | 81 | # Plot a graph with custom kwargs that are passed to the draw function 82 | def test_plot_with_custom_kwargs(self, mocker): 83 | # Arrange 84 | mock_draw = mocker.patch('graphinate.renderers.matplotlib.draw') 85 | mock_pyplot = mocker.patch('graphinate.renderers.matplotlib.pyplot') 86 | mock_ax = mocker.MagicMock() 87 | mock_fig = mocker.MagicMock() 88 | mock_pyplot.gca.return_value = mock_ax 89 | mock_pyplot.gcf.return_value = mock_fig 90 | 91 | graph = nx.Graph(name="Test Graph") 92 | custom_kwargs = {'node_size': 500, 'alpha': 0.8, 'width': 2.0} 93 | 94 | # Act 95 | from graphinate.renderers.matplotlib import plot 96 | plot(graph, **custom_kwargs) 97 | 98 | # Assert 99 | mock_draw.assert_called_once_with(graph, True, False, **custom_kwargs) 100 | mock_ax.margins.assert_called_once_with(0.10) 101 | mock_fig.suptitle.assert_called_once_with("Test Graph") 102 | mock_fig.tight_layout.assert_called_once() 103 | mock_pyplot.show.assert_called_once() 104 | 105 | # Plot a graph with a custom name that appears in the figure title 106 | def test_plot_with_custom_graph_name(self, mocker): 107 | # Arrange 108 | mock_draw = mocker.patch('graphinate.renderers.matplotlib.draw') 109 | mock_pyplot = mocker.patch('graphinate.renderers.matplotlib.pyplot') 110 | mock_ax = mocker.MagicMock() 111 | mock_fig = mocker.MagicMock() 112 | mock_pyplot.gca.return_value = mock_ax 113 | mock_pyplot.gcf.return_value = mock_fig 114 | 115 | custom_name = "My Special Graph Visualization" 116 | graph = nx.Graph(name=custom_name) 117 | graph.add_node(1) 118 | 119 | # Act 120 | from graphinate.renderers.matplotlib import plot 121 | plot(graph) 122 | 123 | # Assert 124 | mock_draw.assert_called_once_with(graph, True, False) 125 | mock_ax.margins.assert_called_once_with(0.10) 126 | mock_fig.suptitle.assert_called_once_with(custom_name) 127 | mock_fig.tight_layout.assert_called_once() 128 | mock_pyplot.show.assert_called_once() 129 | 130 | # Plot an empty graph (no nodes or edges) 131 | def test_plot_empty_graph(self, mocker): 132 | # Arrange 133 | mock_draw = mocker.patch('graphinate.renderers.matplotlib.draw') 134 | mock_pyplot = mocker.patch('graphinate.renderers.matplotlib.pyplot') 135 | mock_ax = mocker.MagicMock() 136 | mock_fig = mocker.MagicMock() 137 | mock_pyplot.gca.return_value = mock_ax 138 | mock_pyplot.gcf.return_value = mock_fig 139 | 140 | graph = nx.Graph(name="Empty Graph") 141 | 142 | # Act 143 | from graphinate.renderers.matplotlib import plot 144 | plot(graph) 145 | 146 | # Assert 147 | mock_draw.assert_called_once_with(graph, True, False) 148 | mock_ax.margins.assert_called_once_with(0.10) 149 | mock_fig.suptitle.assert_called_once_with("Empty Graph") 150 | mock_fig.tight_layout.assert_called_once() 151 | mock_pyplot.show.assert_called_once() 152 | 153 | # Plot a graph with no name attribute 154 | def test_plot_graph_without_name(self, mocker): 155 | # Arrange 156 | mock_draw = mocker.patch('graphinate.renderers.matplotlib.draw') 157 | mock_pyplot = mocker.patch('graphinate.renderers.matplotlib.pyplot') 158 | mock_ax = mocker.MagicMock() 159 | mock_fig = mocker.MagicMock() 160 | mock_pyplot.gca.return_value = mock_ax 161 | mock_pyplot.gcf.return_value = mock_fig 162 | 163 | graph = nx.Graph() # No name provided 164 | graph.add_node(1) 165 | 166 | # Act 167 | from graphinate.renderers.matplotlib import plot 168 | plot(graph) 169 | 170 | # Assert 171 | mock_draw.assert_called_once_with(graph, True, False) 172 | mock_ax.margins.assert_called_once_with(0.10) 173 | mock_fig.suptitle.assert_called_once_with("") # Empty string expected 174 | mock_fig.tight_layout.assert_called_once() 175 | mock_pyplot.show.assert_called_once() 176 | 177 | # Plot a very large graph with many nodes and edges 178 | def test_plot_large_graph(self, mocker): 179 | # Arrange 180 | mock_draw = mocker.patch('graphinate.renderers.matplotlib.draw') 181 | mock_pyplot = mocker.patch('graphinate.renderers.matplotlib.pyplot') 182 | mock_ax = mocker.MagicMock() 183 | mock_fig = mocker.MagicMock() 184 | mock_pyplot.gca.return_value = mock_ax 185 | mock_pyplot.gcf.return_value = mock_fig 186 | 187 | # Create a large graph 188 | graph = nx.complete_graph(100) # 100 nodes, fully connected 189 | graph.name = "Large Complete Graph" 190 | 191 | # Add labels to nodes and edges 192 | for node in graph.nodes(): 193 | graph.nodes[node]['label'] = f"Node {node}" 194 | for edge in graph.edges(): 195 | graph.edges[edge]['label'] = f"Edge {edge[0]}-{edge[1]}" 196 | 197 | # Act 198 | from graphinate.renderers.matplotlib import plot 199 | plot(graph) 200 | 201 | # Assert 202 | mock_draw.assert_called_once_with(graph, True, False) 203 | mock_ax.margins.assert_called_once_with(0.10) 204 | mock_fig.suptitle.assert_called_once_with("Large Complete Graph") 205 | mock_fig.tight_layout.assert_called_once() 206 | mock_pyplot.show.assert_called_once() 207 | 208 | # Plot a graph with custom node attributes that aren't 'label' 209 | def test_plot_with_custom_node_attributes(self, mocker): 210 | # Arrange 211 | mock_draw = mocker.patch('graphinate.renderers.matplotlib.draw') 212 | mock_pyplot = mocker.patch('graphinate.renderers.matplotlib.pyplot') 213 | mock_ax = mocker.MagicMock() 214 | mock_fig = mocker.MagicMock() 215 | mock_pyplot.gca.return_value = mock_ax 216 | mock_pyplot.gcf.return_value = mock_fig 217 | 218 | graph = nx.Graph(name="Graph with Custom Node Attributes") 219 | graph.add_node(1, label="Node 1", weight=10, category="A") 220 | graph.add_node(2, label="Node 2", weight=5, category="B") 221 | graph.add_edge(1, 2) 222 | 223 | # Act 224 | from graphinate.renderers.matplotlib import plot 225 | plot(graph) 226 | 227 | # Assert 228 | mock_draw.assert_called_once_with(graph, True, False) 229 | mock_ax.margins.assert_called_once_with(0.10) 230 | mock_fig.suptitle.assert_called_once_with("Graph with Custom Node Attributes") 231 | mock_fig.tight_layout.assert_called_once() 232 | mock_pyplot.show.assert_called_once() 233 | 234 | # Plot a graph with custom edge attributes that aren't 'label' 235 | def test_plot_with_custom_edge_attributes(self, mocker): 236 | # Arrange 237 | mock_draw = mocker.patch('graphinate.renderers.matplotlib.draw') 238 | mock_pyplot = mocker.patch('graphinate.renderers.matplotlib.pyplot') 239 | mock_ax = mocker.MagicMock() 240 | mock_fig = mocker.MagicMock() 241 | mock_pyplot.gca.return_value = mock_ax 242 | mock_pyplot.gcf.return_value = mock_fig 243 | 244 | graph = nx.Graph(name="Graph with Custom Edge Attributes") 245 | graph.add_node(1, label="Node 1") 246 | graph.add_node(2, label="Node 2") 247 | graph.add_edge(1, 2, label="Edge 1-2", weight=5, type="connection") 248 | 249 | # Act 250 | from graphinate.renderers.matplotlib import plot 251 | plot(graph, with_edge_labels=True) 252 | 253 | # Assert 254 | mock_draw.assert_called_once_with(graph, True, True) 255 | mock_ax.margins.assert_called_once_with(0.10) 256 | mock_fig.suptitle.assert_called_once_with("Graph with Custom Edge Attributes") 257 | mock_fig.tight_layout.assert_called_once() 258 | mock_pyplot.show.assert_called_once() 259 | -------------------------------------------------------------------------------- /tests/graphinate/test_builders.py: -------------------------------------------------------------------------------- 1 | import networkx as nx 2 | import pytest 3 | 4 | import graphinate 5 | import graphinate.builders 6 | from graphinate import GraphType 7 | 8 | graph_types = [ 9 | (nx.Graph(), GraphType.Graph), 10 | (nx.DiGraph(), GraphType.DiGraph), 11 | (nx.MultiGraph(), GraphType.MultiGraph), 12 | (nx.MultiDiGraph(), GraphType.MultiDiGraph) 13 | ] 14 | 15 | 16 | @pytest.mark.parametrize(('graph', 'expected_graph_type'), graph_types) 17 | def test_returns_graph_type_for_graph(graph, expected_graph_type): 18 | # Act 19 | actual_graph_type = GraphType.of(graph) 20 | 21 | # Assert 22 | assert actual_graph_type == expected_graph_type 23 | 24 | 25 | def test_networkx_builder__empty_model(): 26 | # arrange 27 | name = "" 28 | graph_model = graphinate.model(name=name) 29 | 30 | # act 31 | builder = graphinate.builders.NetworkxBuilder(graph_model) 32 | graph = builder.build() 33 | 34 | # assert 35 | assert isinstance(graph, nx.Graph) 36 | assert graph.graph['name'] == name 37 | 38 | 39 | @pytest.mark.parametrize('graph_type', list(graphinate.GraphType)) 40 | def test_networkx_builder__graph_type(graph_type): 41 | # arrange 42 | name = str(graph_type) 43 | graph_model = graphinate.model(name=name) 44 | 45 | @graph_model.edge() 46 | def edge(): 47 | for i in range(5): 48 | yield {'source': i, 'target': i + 1} 49 | if graph_type in (graphinate.GraphType.DiGraph, graphinate.GraphType.MultiDiGraph): 50 | yield {'source': i + 1, 'target': i} 51 | if graph_type in (graphinate.GraphType.MultiGraph, graphinate.GraphType.MultiDiGraph): 52 | yield {'source': i, 'target': i + 1} 53 | 54 | # act 55 | builder = graphinate.builders.NetworkxBuilder(graph_model, graph_type=graph_type) 56 | graph = builder.build() 57 | 58 | # assert 59 | assert isinstance(graph, nx.Graph) 60 | assert graph.graph['name'] == name 61 | 62 | 63 | def test_networkx_builder_repeating_nodes(): 64 | # arrange 65 | name = 'Repeating Nodes' 66 | graph_model = graphinate.GraphModel(name=name) 67 | 68 | @graph_model.node() 69 | def node(): 70 | for i in range(5): 71 | yield i 72 | yield i 73 | 74 | # act 75 | builder = graphinate.builders.NetworkxBuilder(graph_model) 76 | graph: nx.Graph = builder.build() 77 | 78 | # assert 79 | assert isinstance(graph, nx.Graph) 80 | assert graph.graph['name'] == name 81 | assert all(graph.nodes[n]['magnitude'] == 2 for n in graph) 82 | 83 | 84 | @pytest.mark.parametrize('weight', [1.0, 1.5]) 85 | def test_networkx_builder_repeating_edges(weight): 86 | # arrange 87 | name = 'Repeating Edges' 88 | graph_model = graphinate.GraphModel(name=name) 89 | 90 | @graph_model.edge(weight=weight) 91 | def edge(): 92 | for i in range(5): 93 | e = {'source': i, 'target': i + 1} 94 | yield e 95 | yield e 96 | 97 | # act 98 | builder = graphinate.builders.NetworkxBuilder(graph_model) 99 | graph = builder.build() 100 | 101 | # assert 102 | assert isinstance(graph, nx.Graph) 103 | assert graph.graph['name'] == name 104 | assert all(m == weight * 2 for *_, m in graph.edges.data('weight')) 105 | 106 | 107 | def test_networkx_builder_simple_tuple(): 108 | # arrange 109 | name = 'Simple Tuple' 110 | graph_model = graphinate.GraphModel(name=name) 111 | 112 | @graph_model.edge() 113 | def edge(): 114 | for i in range(5): 115 | yield {'source': (i,), 'target': (i + 1,)} 116 | 117 | # act 118 | builder = graphinate.builders.NetworkxBuilder(graph_model) 119 | graph = builder.build() 120 | 121 | # assert 122 | assert isinstance(graph, nx.Graph) 123 | assert graph.graph['name'] == name 124 | 125 | 126 | @pytest.mark.parametrize('execution_number', range(5)) 127 | def test_networkx_builder__map_graph_model(execution_number, map_graph_model): 128 | # arrange 129 | country_count, city_count, graph_model = map_graph_model 130 | person_count = city_count 131 | 132 | # act 133 | builder = graphinate.builders.NetworkxBuilder(graph_model) 134 | graph = builder.build() 135 | 136 | # assert 137 | assert graph.order() == country_count + city_count + person_count 138 | assert graph.graph['node_types']['country'] == country_count 139 | assert graph.graph['node_types']['city'] == city_count 140 | 141 | 142 | @pytest.mark.parametrize('execution_number', range(5)) 143 | def test_d3_builder__map_graph_model(execution_number, map_graph_model): 144 | # arrange 145 | country_count, city_count, graph_model = map_graph_model 146 | person_count = city_count 147 | 148 | # act 149 | builder = graphinate.builders.D3Builder(graph_model) 150 | actual_graph = builder.build() 151 | 152 | # assert 153 | assert actual_graph['directed'] is False 154 | assert actual_graph['multigraph'] is False 155 | assert actual_graph['graph']['name'] == 'Map' 156 | assert actual_graph['graph']['node_types']['country'] == country_count 157 | assert actual_graph['graph']['node_types']['city'] == city_count 158 | assert len(actual_graph['nodes']) == country_count + city_count + person_count 159 | 160 | 161 | def test_d3_builder__map_graph_model__both_specific_ids(map_graph_model): 162 | # arrange 163 | _, _, graph_model = map_graph_model 164 | 165 | # act 166 | builder = graphinate.builders.D3Builder(graph_model) 167 | actual_graph = builder.build(country_id="1", city_id="1") 168 | 169 | # assert 170 | assert actual_graph['directed'] is False 171 | assert actual_graph['multigraph'] is False 172 | assert actual_graph['graph']['name'] == 'Map' 173 | assert actual_graph['graph']['node_types'].get('city', 0) in (0, 1) 174 | assert actual_graph['graph']['node_types']['country'] == 1 175 | assert len(actual_graph['nodes']) in (1, 3) 176 | 177 | 178 | @pytest.mark.parametrize('execution_number', range(5)) 179 | def test_graphql_builder__map_graph_model(execution_number, map_graph_model, graphql_query): 180 | # arrange 181 | expected_country_count, expected_city_count, graph_model = map_graph_model 182 | expected_person_count = expected_city_count 183 | 184 | # act 185 | builder = graphinate.builders.GraphQLBuilder(graph_model) 186 | 187 | import strawberry 188 | schema: strawberry.Schema = builder.build() 189 | execution_result = schema.execute_sync(graphql_query) 190 | actual_graph = execution_result.data 191 | 192 | node_ids: set = {v['id'] for v in actual_graph['nodes']} 193 | edges = actual_graph['edges'] 194 | edges_source_ids: set = {v['source']['id'] for v in edges} 195 | edges_targets_ids: set = {v['target']['id'] for v in edges} 196 | 197 | # assert 198 | assert actual_graph 199 | assert actual_graph['graph'] 200 | assert actual_graph['nodes'] 201 | assert actual_graph['edges'] 202 | assert actual_graph['graph']['name'] == 'Map' 203 | node_types_counts = {c['name']: c['value'] for c in actual_graph['graph']['nodeTypeCounts']} 204 | assert node_types_counts['country'] == expected_country_count 205 | assert node_types_counts['city'] == expected_city_count 206 | assert len(actual_graph['nodes']) == expected_country_count + expected_city_count + expected_person_count 207 | assert edges_source_ids.issubset(node_ids) 208 | assert edges_targets_ids.issubset(node_ids) 209 | assert node_ids.issuperset(edges_source_ids) 210 | assert node_ids.issuperset(edges_targets_ids) 211 | 212 | 213 | graphql_operations_cases = [ 214 | ("""{ 215 | empty: measure(measure: is_empty){...Details} 216 | directed: measure(measure: is_directed){...Details} 217 | planar: measure(measure: is_planar){...Details} 218 | connectivity: measure(measure: is_connected){...Details} 219 | node_connectivity: measure(measure: node_connectivity){...Details} 220 | threshold_graph: measure(measure: is_threshold_graph){...Details} 221 | } 222 | fragment Details on Measure {name value} 223 | """, { 224 | "empty": { 225 | "name": "is_empty", 226 | "value": 0 227 | }, 228 | "directed": { 229 | "name": "is_directed", 230 | "value": 0 231 | }, 232 | "planar": { 233 | "name": "is_planar", 234 | "value": 1 235 | }, 236 | "connectivity": { 237 | "name": "is_connected", 238 | "value": 1 239 | }, 240 | "node_connectivity": { 241 | "name": "node_connectivity", 242 | "value": 2 243 | }, 244 | "threshold_graph": { 245 | "name": "is_threshold_graph", 246 | "value": 0 247 | } 248 | }), 249 | (( 250 | 'query Graph {\n' 251 | 'nodes(nodeId: "KDAsKQ==") {type label}\n' 252 | 'edges(edgeId: "KCdLREFzS1E9PScsICdLREVzS1E9PScp") {type label}\n' 253 | '}' 254 | ), 255 | # noqa: E501 256 | { 257 | "nodes": [ 258 | { 259 | "type": "node", 260 | "label": "0" 261 | } 262 | ], 263 | "edges": [ 264 | { 265 | "type": "edge", 266 | "label": "0 ⟹ 1" 267 | } 268 | ] 269 | }), 270 | ("mutation {refresh}", {'refresh': True}) 271 | ] 272 | 273 | 274 | @pytest.mark.parametrize(('graphql_query', 'expected_response'), graphql_operations_cases) 275 | def test_graphql_builder_query(octagonal_graph_model, graphql_query, expected_response): 276 | # act 277 | builder = graphinate.builders.GraphQLBuilder(octagonal_graph_model) 278 | 279 | import strawberry 280 | schema: strawberry.Schema = builder.build( 281 | default_node_attributes=graphinate.builders.Builder.default_node_attributes 282 | ) 283 | execution_result = schema.execute_sync(graphql_query) 284 | actual_response = execution_result.data 285 | 286 | # assert 287 | assert actual_response == expected_response 288 | 289 | 290 | def test_graphql_builder__ast_model__graph_query(ast_graph_model, graphql_query): 291 | # act 292 | builder = graphinate.builders.GraphQLBuilder(ast_graph_model) 293 | import strawberry 294 | schema: strawberry.Schema = builder.build() 295 | execution_result = schema.execute_sync(graphql_query) 296 | actual_graph = execution_result.data 297 | 298 | # assert 299 | assert actual_graph 300 | assert actual_graph['graph'] 301 | assert actual_graph['nodes'] 302 | assert actual_graph['edges'] 303 | assert actual_graph['graph']['name'] == 'AST Graph' 304 | node_types_counts = {c['name']: c['value'] for c in actual_graph['graph']['nodeTypeCounts']} 305 | assert node_types_counts 306 | -------------------------------------------------------------------------------- /tests/graphinate/test_cli.py: -------------------------------------------------------------------------------- 1 | import sys 2 | 3 | import pytest 4 | from click.testing import CliRunner 5 | 6 | import graphinate 7 | from graphinate.cli import ImportFromStringError, cli, import_from_string 8 | 9 | EXAMPLES_MATH = 'examples/math' 10 | 11 | 12 | @pytest.fixture 13 | def runner(): 14 | return CliRunner() 15 | 16 | 17 | def test_save_model(octagonal_graph_model, runner): 18 | with runner.isolated_filesystem(): 19 | result = runner.invoke(cli, ['save', '-m', octagonal_graph_model]) 20 | assert result.exit_code == 0 21 | 22 | 23 | def test_save_model_reference(runner): 24 | sys.path.append('examples/math') 25 | result = runner.invoke(cli, ['save', '-m', "polygonal_graph:model"]) 26 | assert result.exit_code == 0 27 | 28 | 29 | def test_save_malformed_model_reference(runner): 30 | with runner.isolated_filesystem(): 31 | result = runner.invoke(cli, ['save', '-m', "malformed_model_reference"]) 32 | 33 | assert result.exit_code == 2 34 | 35 | 36 | def test_import_from_string(): 37 | sys.path.append(EXAMPLES_MATH) 38 | actual = import_from_string("polygonal_graph:model") 39 | assert isinstance(actual, graphinate.GraphModel) 40 | assert actual.name == "Octagonal Graph" 41 | 42 | 43 | import_from_string_error_cases = [ 44 | ("does_not_exist:model", "Could not import module 'does_not_exist'."), 45 | ("polygonal_graph:does_not_exist", "Attribute 'does_not_exist' not found in module 'polygonal_graph'."), 46 | ("wrong_format", "Import string 'wrong_format' must be in format ':'.") 47 | ] 48 | 49 | 50 | @pytest.mark.parametrize(('case', 'message'), import_from_string_error_cases) 51 | def test_import_from_string__error(case, message): 52 | sys.path.append(EXAMPLES_MATH) 53 | with pytest.raises(ImportFromStringError, match=message): 54 | _ = import_from_string(case) 55 | 56 | 57 | import_from_string_not_str_cases = [ 58 | 0, 59 | None 60 | ] 61 | 62 | 63 | @pytest.mark.parametrize('case', import_from_string_not_str_cases) 64 | def test_import_from_string__not_str(case): 65 | actual = import_from_string(case) 66 | assert actual == case 67 | -------------------------------------------------------------------------------- /tests/graphinate/test_color.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from graphinate.color import color_hex 4 | 5 | colors = [ 6 | ([0, 0, 0], "#000000"), 7 | ([0.5, 0.5, 0.5], "#7f7f7f"), 8 | ([1, 1, 1], "#010101"), 9 | ([2, 2, 2], "#020202"), 10 | ([50, 50, 50], "#323232"), 11 | ([100, 100, 100], "#646464"), 12 | ([128, 128, 128], "#808080"), 13 | ([255, 255, 255], "#ffffff"), 14 | ("Not a Sequence", "Not a Sequence") 15 | ] 16 | 17 | 18 | @pytest.mark.parametrize(('color', 'expected_color_hex'), colors) 19 | def test_color_hex(color, expected_color_hex): 20 | # act 21 | actual_color_hex = color_hex(color) 22 | 23 | # assert 24 | assert actual_color_hex == expected_color_hex 25 | 26 | 27 | def test_color_hex_error(): 28 | with pytest.raises(ValueError, 29 | match="Input values should either be a float between 0 and 1 or an int between 0 and 255"): 30 | _ = color_hex(["a", "b", "c"]) 31 | -------------------------------------------------------------------------------- /tests/graphinate/test_converters.py: -------------------------------------------------------------------------------- 1 | import math 2 | 3 | import pytest 4 | 5 | from graphinate import constants, converters 6 | 7 | base_cases = [ 8 | ('1', '1'), 9 | ('1.1', '1.1'), 10 | (1, 1), 11 | (1.1, 1.1), 12 | (0, 0), 13 | (True, True) 14 | ] 15 | 16 | value_handling_cases = [ 17 | *base_cases, 18 | ('Infinity', math.inf), 19 | ('-Infinity', -math.inf), 20 | ('+Infinity', math.inf) 21 | ] 22 | 23 | inf_handling_cases = [ 24 | *base_cases, 25 | (math.inf, 'Infinity'), 26 | (-math.inf, '-Infinity') 27 | ] 28 | 29 | 30 | @pytest.mark.parametrize(('case', 'expected'), value_handling_cases) 31 | def test_value_to_infnum(case, expected): 32 | # act 33 | actual = converters.value_to_infnum(case) 34 | 35 | # assert 36 | assert actual == expected 37 | 38 | 39 | @pytest.mark.parametrize(('case', 'expected'), inf_handling_cases) 40 | def test_infnum_to_value(case, expected): 41 | # act 42 | actual = converters.infnum_to_value(case) 43 | 44 | # assert 45 | assert actual == expected 46 | 47 | 48 | @pytest.mark.parametrize('case', [0, None, "", False]) 49 | def test_label_converter__value__falsy(case): 50 | actual = converters.label_converter(case, delimiter=constants.DEFAULT_NODE_DELIMITER) 51 | assert actual == case 52 | 53 | 54 | def test_encoding(): 55 | expected_edge = (("parent_a", "child_a"), ("parent_b", "child_b")) 56 | 57 | edge_id = converters.encode_edge_id(expected_edge) 58 | actual_edge = converters.decode_edge_id(edge_id) 59 | 60 | assert actual_edge == expected_edge 61 | -------------------------------------------------------------------------------- /tests/graphinate/test_modeling.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | import graphinate 4 | import graphinate.typing 5 | 6 | 7 | def test_graph_model(map_graph_model): 8 | # arrange 9 | expected_country_count, expected_city_count, graph_model = map_graph_model 10 | country_type_id = (graphinate.typing.UniverseNode, 'country') 11 | city_type_id = ('country', 'city') 12 | 13 | # act 14 | actual_model_count = len(graph_model._node_models) 15 | actual_country_count = len(list(graph_model._node_models[country_type_id][0].generator())) 16 | actual_city_count = len(list(graph_model._node_models[city_type_id][0].generator())) 17 | 18 | # assert 19 | assert actual_model_count == 3 20 | assert actual_country_count == expected_country_count # len(country_ids) 21 | assert actual_city_count == expected_city_count # len(city_ids) 22 | 23 | 24 | def test_graph_model__add__(): 25 | first_model = graphinate.model(name='First Model') 26 | second_model = graphinate.model(name='Second Model') 27 | 28 | actual_model = first_model + second_model 29 | 30 | assert actual_model.name == 'First Model + Second Model' 31 | 32 | 33 | def test_graph_model_validate_node_parameters(): 34 | graph_model = graphinate.model(name='Graph with invalid node supplier') 35 | 36 | with pytest.raises(graphinate.modeling.GraphModelError): 37 | @graph_model.node() 38 | def invalid_node_supplier(wrong_parameter=None): 39 | yield 1 40 | -------------------------------------------------------------------------------- /tests/graphinate/test_server.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from starlette.responses import FileResponse 3 | 4 | import graphinate.server.starlette.views 5 | from graphinate.server.web import get_static_path 6 | 7 | 8 | def test_get_static_path(): 9 | path = 'this_is_a_path' 10 | actual_path = get_static_path(path) 11 | assert f'static/{path}' in actual_path.as_posix() 12 | 13 | 14 | @pytest.mark.asyncio 15 | async def test_favicon(): 16 | actual = await graphinate.server.starlette.views.favicon(None) 17 | 18 | assert isinstance(actual, FileResponse) 19 | assert actual.media_type == 'image/png' 20 | assert 'src/graphinate/server/web/static/images/logo-128.png' in actual.path 21 | 22 | --------------------------------------------------------------------------------