├── .github └── workflows │ ├── codspeed.yml │ └── python-package.yml ├── .gitignore ├── CHANGELOG.md ├── LICENSE ├── README.md ├── docs ├── Backend-Caches.md ├── ImplementationNotes.md ├── README.md ├── What-About-Neptune-Dramatic-Retelling.md ├── What-About-Neptune.md ├── grand.png └── reference │ ├── backends │ ├── _dataframe.py.md │ ├── _dynamodb.py.md │ ├── _gremlin.py.md │ ├── _igraph.py.md │ ├── _networkit.py.md │ ├── _networkx.py.md │ ├── _sqlbackend.py.md │ ├── backend.py.md │ ├── backends.md │ ├── dynamodb.py.md │ ├── gremlin.py.md │ ├── igraph.py.md │ ├── metadatastore.py.md │ ├── networkit.py.md │ ├── networkx.py.md │ └── sqlbackend.py.md │ ├── dialects │ └── dialects.md │ ├── grand │ └── grand.md │ └── setup.py.md ├── grand ├── __init__.py ├── backends │ ├── __init__.py │ ├── _dataframe.py │ ├── _dynamodb.py │ ├── _gremlin.py │ ├── _igraph.py │ ├── _networkit.py │ ├── _networkx.py │ ├── _sqlbackend.py │ ├── backend.py │ ├── metadatastore.py │ ├── test_backends.py │ ├── test_cached_backend.py │ └── test_metadatastore.py ├── dialects │ ├── __init__.py │ └── test_dialect.py └── test_graph.py ├── pyproject.toml └── uv.lock /.github/workflows/codspeed.yml: -------------------------------------------------------------------------------- 1 | name: codspeed-benchmarks 2 | 3 | on: 4 | push: 5 | branches: 6 | - "master" 7 | pull_request: 8 | # `workflow_dispatch` allows CodSpeed to trigger backtest 9 | # performance analysis in order to generate initial data. 10 | workflow_dispatch: 11 | 12 | jobs: 13 | benchmarks: 14 | runs-on: ubuntu-latest 15 | steps: 16 | - uses: actions/checkout@v3 17 | - uses: actions/setup-python@v3 18 | with: 19 | python-version: "3.11" 20 | 21 | - name: Install dependencies 22 | run: | 23 | python -m pip install --upgrade pip 24 | pip install pytest pytest-cov pytest-codspeed 25 | # Install "cmake", "cython" for networkit. Must happen first: 26 | pip install --upgrade cmake cython 27 | pip install -e ".[sql]" 28 | 29 | - name: Run benchmarks 30 | uses: CodSpeedHQ/action@v3 31 | with: 32 | # token: ${{ secrets.CODSPEED_TOKEN }} 33 | run: pytest . --codspeed 34 | -------------------------------------------------------------------------------- /.github/workflows/python-package.yml: -------------------------------------------------------------------------------- 1 | # This workflow will install Python dependencies, run tests and lint with a variety of Python versions 2 | # For more information see: https://help.github.com/actions/language-and-framework-guides/using-python-with-github-actions 3 | 4 | name: Python package 5 | 6 | on: 7 | push: 8 | branches: [ master ] 9 | pull_request: 10 | branches: [ master ] 11 | 12 | jobs: 13 | build: 14 | 15 | runs-on: ubuntu-latest 16 | strategy: 17 | matrix: 18 | python-version: ['3.10', '3.11', '3.12', '3.13'] 19 | 20 | steps: 21 | - uses: actions/checkout@v2 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 | pip install flake8 pytest pytest-cov 30 | # Install "cmake", "cython" for networkit. Must happen first: 31 | pip install --upgrade cmake cython 32 | pip install -e ".[sql,networkit,igraph]" 33 | - name: Lint with flake8 34 | run: | 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 | pytest --cov=./ --cov-report=xml 42 | - name: Codecov 43 | uses: codecov/codecov-action@v1.0.13 44 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .vscode/ 2 | notebooks/ 3 | # Created by https://www.toptal.com/developers/gitignore/api/macos,python,windows,jupyternotebooks,visualstudiocode 4 | # Edit at https://www.toptal.com/developers/gitignore?templates=macos,python,windows,jupyternotebooks,visualstudiocode 5 | 6 | ### JupyterNotebooks ### 7 | # gitignore template for Jupyter Notebooks 8 | # website: http://jupyter.org/ 9 | 10 | .ipynb_checkpoints 11 | */.ipynb_checkpoints/* 12 | 13 | # IPython 14 | profile_default/ 15 | ipython_config.py 16 | 17 | # Remove previous ipynb_checkpoints 18 | # git rm -r .ipynb_checkpoints/ 19 | 20 | ### macOS ### 21 | # General 22 | .DS_Store 23 | .AppleDouble 24 | .LSOverride 25 | 26 | # Icon must end with two \r 27 | Icon 28 | 29 | # Thumbnails 30 | ._* 31 | 32 | # Files that might appear in the root of a volume 33 | .DocumentRevisions-V100 34 | .fseventsd 35 | .Spotlight-V100 36 | .TemporaryItems 37 | .Trashes 38 | .VolumeIcon.icns 39 | .com.apple.timemachine.donotpresent 40 | 41 | # Directories potentially created on remote AFP share 42 | .AppleDB 43 | .AppleDesktop 44 | Network Trash Folder 45 | Temporary Items 46 | .apdisk 47 | 48 | ### Python ### 49 | # Byte-compiled / optimized / DLL files 50 | __pycache__/ 51 | *.py[cod] 52 | *$py.class 53 | 54 | # C extensions 55 | *.so 56 | 57 | # Distribution / packaging 58 | .Python 59 | build/ 60 | develop-eggs/ 61 | dist/ 62 | downloads/ 63 | eggs/ 64 | .eggs/ 65 | lib/ 66 | lib64/ 67 | parts/ 68 | sdist/ 69 | var/ 70 | wheels/ 71 | pip-wheel-metadata/ 72 | share/python-wheels/ 73 | *.egg-info/ 74 | .installed.cfg 75 | *.egg 76 | MANIFEST 77 | 78 | # PyInstaller 79 | # Usually these files are written by a python script from a template 80 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 81 | *.manifest 82 | *.spec 83 | 84 | # Installer logs 85 | pip-log.txt 86 | pip-delete-this-directory.txt 87 | 88 | # Unit test / coverage reports 89 | htmlcov/ 90 | .tox/ 91 | .nox/ 92 | .coverage 93 | .coverage.* 94 | .cache 95 | nosetests.xml 96 | coverage.xml 97 | *.cover 98 | *.py,cover 99 | .hypothesis/ 100 | .pytest_cache/ 101 | pytestdebug.log 102 | 103 | # Translations 104 | *.mo 105 | *.pot 106 | 107 | # Django stuff: 108 | *.log 109 | local_settings.py 110 | db.sqlite3 111 | db.sqlite3-journal 112 | 113 | # Flask stuff: 114 | instance/ 115 | .webassets-cache 116 | 117 | # Scrapy stuff: 118 | .scrapy 119 | 120 | # Sphinx documentation 121 | docs/_build/ 122 | doc/_build/ 123 | 124 | # PyBuilder 125 | target/ 126 | 127 | # Jupyter Notebook 128 | 129 | # IPython 130 | 131 | # pyenv 132 | .python-version 133 | 134 | # pipenv 135 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 136 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 137 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 138 | # install all needed dependencies. 139 | #Pipfile.lock 140 | 141 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow 142 | __pypackages__/ 143 | 144 | # Celery stuff 145 | celerybeat-schedule 146 | celerybeat.pid 147 | 148 | # SageMath parsed files 149 | *.sage.py 150 | 151 | # Environments 152 | .env 153 | .venv 154 | env/ 155 | venv/ 156 | ENV/ 157 | env.bak/ 158 | venv.bak/ 159 | 160 | # Spyder project settings 161 | .spyderproject 162 | .spyproject 163 | 164 | # Rope project settings 165 | .ropeproject 166 | 167 | # mkdocs documentation 168 | /site 169 | 170 | # mypy 171 | .mypy_cache/ 172 | .dmypy.json 173 | dmypy.json 174 | 175 | # Pyre type checker 176 | .pyre/ 177 | 178 | # pytype static type analyzer 179 | .pytype/ 180 | 181 | ### VisualStudioCode ### 182 | .vscode/* 183 | !.vscode/settings.json 184 | !.vscode/tasks.json 185 | !.vscode/launch.json 186 | !.vscode/extensions.json 187 | *.code-workspace 188 | 189 | ### VisualStudioCode Patch ### 190 | # Ignore all local history of files 191 | .history 192 | 193 | ### Windows ### 194 | # Windows thumbnail cache files 195 | Thumbs.db 196 | Thumbs.db:encryptable 197 | ehthumbs.db 198 | ehthumbs_vista.db 199 | 200 | # Dump file 201 | *.stackdump 202 | 203 | # Folder config file 204 | [Dd]esktop.ini 205 | 206 | # Recycle Bin used on file shares 207 | $RECYCLE.BIN/ 208 | 209 | # Windows Installer files 210 | *.cab 211 | *.msi 212 | *.msix 213 | *.msm 214 | *.msp 215 | 216 | # Windows shortcuts 217 | *.lnk 218 | 219 | # End of https://www.toptal.com/developers/gitignore/api/macos,python,windows,jupyternotebooks,visualstudiocode 220 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # CHANGELOG 2 | 3 | ## **0.7.0** (May 25, 2025) 4 | 5 | - Housekeeping: 6 | - Upgrade from setup.py to pyproject.toml (#62, thanks @AdrianVollmer!) 7 | - Bugfixes: 8 | - Fix NetworkX `degree` parity (#64) 9 | 10 | ## **0.6.0** (December 8, 2024) 11 | 12 | - Features: 13 | - Upgrade codspeed CI runner to v3 14 | - Bugfixes: 15 | - Fix `get_node_by_id` method in `SQLBackend` (#59. thanks @davidmezzetti!) 16 | - Add `remove_node` to the `CachedBackend` (#59. thanks @davidmezzetti!) 17 | 18 | ## **0.5.3** (December 7, 2024) 19 | 20 | - Features: 21 | - Add `remove_node` method to SQLBackend (#58. thanks @davidmezzetti!) 22 | - Bugfixes: 23 | - Modify sqlite backend `has_node` to return a bool vs int (#58. thanks @davidmezzetti!) 24 | - Update method parameters to conform to existing naming standards (#58. thanks @davidmezzetti!) 25 | 26 | ## **0.5.2** (June 4, 2024) 27 | 28 | - Bugfixes: 29 | - Fixes SQLBackend mistakenly referencing Nodes table for enumeration of Edges (#55, #56) 30 | 31 | ## **0.5.1** (May 13, 2024) 32 | 33 | - Bugfixes: 34 | - Fixes SQLBackend bug where graphs would not commit down to disk after transactions (#51, thanks @acthecoder23!) 35 | 36 | ## **0.5.0** (April 17, 2024) 37 | 38 | - Features 39 | - https://github.com/aplbrain/grand/pull/45 (thanks @davidmezzetti!) 40 | - Improved support for SQL backend, including an index on edge.sources and edge.targets 41 | - Improved batch-adding performance for nodes and edges 42 | - Housekeeping 43 | - Removed extra arguments to Graph constructor and improved Graph kwargs handing 44 | - Added `grand.DiGraph` convenience wrapper for directed graphs 45 | - Dialects 46 | - Expanded Networkit dialect test coverage 47 | - Added support for exporting NetworkX graphs by adding `graph` attribute 48 | 49 | ## **0.4.2** (May 7, 2022) 50 | 51 | - Backends 52 | - NEW: `DataFrameBackend` supports operations on a pandas-like API. 53 | - Housekeeping 54 | - Added tests for metadata stores 55 | - Added IGraph and Networkit backends to the standard GitHub Actions CI test suite 56 | 57 | ## **0.4.1** (February 10, 2022) 58 | 59 | - Improvements 60 | - Dialects 61 | - NetworkX Dialect: 62 | - Edges are now reported as EdgeViews or other NetworkX reporting classes. 63 | - Housekeeping 64 | - Removed unused tests 65 | - Removed DotMotif dialect, which is no longer a goal of this repository (and is still possible by passing a grand.Graph#nx to the DotMotif library.) 66 | 67 | ## **0.4.0** 68 | 69 | - Improvements 70 | - Backends 71 | - SQLBackend: 72 | - Add support for user-specified edge column names, with `edge_table_source_column` and `edge_table_target_column` arguments. 73 | - Fix buggy performance when updating nodes and edges. 74 | - Caching support. You can now cache the results from backend methods by wrapping with a class that inherits from `CachedBackend`. 75 | - Housekeeping 76 | - Fix `pip install grand-graph[sql]` and `pip install grand-graph[dynamodb]`, which failed on previous versions due to a faulty setup.py key. 77 | - Rename all backend files. 78 | 79 | ## **0.3.0** 80 | 81 | > This version adds support for Gremlin-compatible graph databases, such as AWS Neptune, TinkerPop, Janus, etc, through the `GremlinBackend`, and loosens the requirements for the base installation of `grand-graph`. You can now install `grand-graph[sql]` or `grand-graph[dynamodb]` to get additional functionality (with additional dependencies). 82 | 83 | - Improvements 84 | - Backends 85 | - Add `GremlinBackend` to the list of supported backends 86 | - Housekeeping 87 | - Remove sqlalchemy and boto3 from the list of requirements for the base install. You can now install these with `pip3 install grand-graph[sql]` or `[dyanmodb]`. 88 | 89 | ## **0.2.0** 90 | 91 | > This version adds a new `IGraphBackend` (non-default install). If you have IGraph installed already, you can now import this backend with `from grand.backends.igraph import IGraphBackend`. 92 | 93 | - Improvements 94 | - Backends 95 | - Add `IGraphBackend` to the list of supported backends 96 | 97 | ## **0.1.0** (January 19, 2021) 98 | 99 | > This version adds dependency-install support to installations with `pip`. (Thanks @Raphtor!) 100 | 101 | - Improvements 102 | - Faster edge-indexing support for the SQLBackend, using compound primary keys rather than columnwise lookups 103 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |
2 | 3 |
Codecov
4 | 5 | 6 | 7 |
8 | 9 |

Graph toolkit interoperability and scalability for Python

10 | 11 | ## Installation 12 | 13 | ```shell 14 | pip install grand-graph 15 | ``` 16 | 17 | ## Example use-cases 18 | 19 | - Write NetworkX commands to analyze true-serverless graph databases using DynamoDB\* 20 | - Query a host graph in SQL for subgraph isomorphisms with DotMotif 21 | - Write iGraph code to construct a graph, and then play with it in Networkit 22 | - Attach node and edge attributes to Networkit or IGraph graphs 23 | 24 | > \* [Neptune is not true-serverless.](docs/What-About-Neptune.md) 25 | 26 | ## Why it's a big deal 27 | 28 | _Grand_ is a Rosetta Stone of graph technologies. A _Grand_ graph has a "Backend," which handles the implementation-details of talking to data on disk (or in the cloud), and an "Dialect", which is your preferred way of talking to a graph. 29 | 30 | For example, here's how you make a graph that is persisted in DynamoDB (the "Backend") but that you can talk to as though it's a `networkx.DiGraph` (the "Dialect"): 31 | 32 | ```python 33 | import grand 34 | 35 | G = grand.Graph(backend=grand.DynamoDBBackend()) 36 | 37 | G.nx.add_node("Jordan", type="Person") 38 | G.nx.add_node("DotMotif", type="Project") 39 | 40 | G.nx.add_edge("Jordan", "DotMotif", type="Created") 41 | 42 | assert len(G.nx.edges()) == 1 43 | assert len(G.nx.nodes()) == 2 44 | ``` 45 | 46 | It doesn't stop there. If you like the way IGraph handles anonymous node insertion (ugh) but you want to handle the graph using regular NetworkX syntax, use a `IGraphDialect` and then switch to a `NetworkXDialect` halfway through: 47 | 48 | ```python 49 | import grand 50 | 51 | G = grand.Graph() 52 | 53 | # Start in igraph: 54 | G.igraph.add_vertices(5) 55 | 56 | # A little bit of networkit: 57 | G.networkit.addNode() 58 | 59 | # And switch to networkx: 60 | assert len(G.nx.nodes()) == 6 61 | 62 | # And back to igraph! 63 | assert len(G.igraph.vs) == 6 64 | ``` 65 | 66 | You should be able to use the "dialect" objects the same way you'd use a real graph from the constituent libraries. For example, here is a NetworkX algorithm running on NetworkX graphs alongside Grand graphs: 67 | 68 | ```python 69 | import networkx as nx 70 | 71 | nx.algorithms.isomorphism.GraphMatcher(networkxGraph, grandGraph.nx) 72 | ``` 73 | 74 | Here is an example of using Networkit, a highly performant graph library, and attaching node/edge attributes, which are not supported by the library by default: 75 | 76 | ```python 77 | import grand 78 | from grand.backends.networkit import NetworkitBackend 79 | 80 | G = grand.Graph(backend=NetworkitBackend()) 81 | 82 | G.nx.add_node("Jordan", type="Person") 83 | G.nx.add_node("Grand", type="Software") 84 | G.nx.add_edge("Jordan", "Grand", weight=1) 85 | 86 | print(G.nx.edges(data=True)) # contains attributes, even though graph is stored in networkit 87 | ``` 88 | 89 | ## Current Support 90 | 91 | 92 | 93 | 94 | 95 |
✅ = Fully Implemented🤔 = In Progress🔴 = Unsupported
96 | 97 | | Dialect | Description & Notes | Status | 98 | | ------------------ | ------------------------ | ------ | 99 | | `IGraphDialect` | Python-IGraph interface | ✅ | 100 | | `NetworkXDialect` | NetworkX-like interface | ✅ | 101 | | `NetworkitDialect` | Networkit-like interface | ✅ | 102 | 103 | | Backend | Description & Notes | Status | 104 | | ------------------ | ---------------------------- | ------ | 105 | | `DataFrameBackend` | Stored in pandas-like tables | ✅ | 106 | | `DynamoDBBackend` | Edge/node tables in DynamoDB | ✅ | 107 | | `GremlinBackend` | For Gremlin datastores | ✅ | 108 | | `IGraphBackend` | An IGraph graph, in memory | ✅ | 109 | | `NetworkitBackend` | A Networkit graph, in memory | ✅ | 110 | | `NetworkXBackend` | A NetworkX graph, in memory | ✅ | 111 | | `SQLBackend` | Two SQL-queryable tables | ✅ | 112 | 113 | You can read more about usage and learn about backends and dialects in [the wiki](https://github.com/aplbrain/grand/wiki). 114 | 115 | ## Citing 116 | 117 | If this tool is helpful to your research, please consider citing it with: 118 | 119 | ```bibtex 120 | # https://doi.org/10.1038/s41598-021-91025-5 121 | @article{Matelsky_Motifs_2021, 122 | title={{DotMotif: an open-source tool for connectome subgraph isomorphism search and graph queries}}, 123 | volume={11}, 124 | ISSN={2045-2322}, 125 | url={http://dx.doi.org/10.1038/s41598-021-91025-5}, 126 | DOI={10.1038/s41598-021-91025-5}, 127 | number={1}, 128 | journal={Scientific Reports}, 129 | publisher={Springer Science and Business Media LLC}, 130 | author={Matelsky, Jordan K. and Reilly, Elizabeth P. and Johnson, Erik C. and Stiso, Jennifer and Bassett, Danielle S. and Wester, Brock A. and Gray-Roncal, William}, 131 | year={2021}, 132 | month={Jun} 133 | } 134 | ``` 135 | 136 | --- 137 | 138 |

Made with 💙 at JHU APL

139 | -------------------------------------------------------------------------------- /docs/Backend-Caches.md: -------------------------------------------------------------------------------- 1 | # Backend Caches 2 | 3 | When a graph changes little or not at all, it may be desirable to cache the results of backend method calls to be reused on subsequent calls. This is especially useful when the backend is a remote service, such as a database or a REST API, where the cost of each method call may be substantial. 4 | 5 | To address this, we recommend the use of a class that inherits from `CachedBackend`. This class will cache the results of backend method calls, and will return the cached results if the method is called again with the same arguments. 6 | 7 | Furthermore, this cache will listen for calls to methods (such as `add_node`) that change the graph, and will automatically dirty the cache to avoid returning invalid results. 8 | 9 | ## Example Usage 10 | 11 | ```python 12 | from grand.backends import SQLBackend, InMemoryCachedBackend 13 | from grand import Graph 14 | 15 | G = Graph(backend=InMemoryCachedBackend(SQLBackend(...))) 16 | 17 | G.nx.degree(42) # May be slow if the graph is very large 18 | G.nx.degree(42) # Will be very fast, since the result is cached 19 | G.backend.clear_cache() # Clear the cache explicitly 20 | ``` 21 | -------------------------------------------------------------------------------- /docs/ImplementationNotes.md: -------------------------------------------------------------------------------- 1 | # Implementation Notes 2 | 3 | 4 | 5 |

Adding nodes is an upsert operation.

6 | 7 | When you call the `add_node` operation on a grand `backend` object, it will check to see if the node already exists in the database. If it does, it will update the node with the new data. If it does not, it will insert the node into the database. Note that there is no difference visible to the user between these two operations. 8 | 9 | If you (as the end-user of this library) want to know whether a node is already present in the graph, you can use the `has_node` operation. 10 | 11 | Alternatively, you can call the `add_node` operation with the `upsert` flag set to False. 12 | -------------------------------------------------------------------------------- /docs/README.md: -------------------------------------------------------------------------------- 1 | # Grand: Documentation 2 | 3 | API reference documentation is available under `reference/` in this directory (https://github.com/aplbrain/grand/tree/master/docs/reference). 4 | 5 | Reference documentation was generated with [Docshund](https://github.com/fitmango/docshund). 6 | -------------------------------------------------------------------------------- /docs/What-About-Neptune-Dramatic-Retelling.md: -------------------------------------------------------------------------------- 1 | # What about Neptune? 2 | 3 | ``` 4 | WIDE SHOT: COMPUTER LAB 5 | 6 | NEPTUNE 7 | 8 | (...Emerging from the sea, 9 | which has materialized in 10 | the corner of the computer 11 | lab, much to the chagrin of 12 | the sysadmin.) 13 | 14 | Thou darest call Grand-DynamoDB the first true-serverless 15 | graph database? [BOOMING] Audacity! 16 | 17 | 18 | JORDAN 19 | 20 | (Without turning or looking 21 | away from laptop) 22 | 23 | Tell me the Neptune pricing model. 24 | 25 | NEPTUNE 26 | 27 | (Rearing from throne of 28 | seafoam, shaking seawater 29 | off of his beard with rage) 30 | 31 | Thou simply payest for thine virtual machine! 32 | 33 | JORDAN 34 | 35 | That sounds serverful to me. 36 | 37 | NEPTUNE 38 | 39 | Yet thou needn't manage thine own server! 40 | 41 | JORDAN 42 | 43 | Actually that kinda sounds like a disadvantage, if 44 | I'm going to the trouble of paying for CPU-hours anyway. 45 | 46 | NEPTUNE 47 | 48 | (Red with fury, seamounts 49 | erupting in vicinity) 50 | 51 | FOOL! If thou would to pay only per-request, thou 52 | haveth DynamoDB, among other offerings! 53 | 54 | JORDAN 55 | 56 | Yep, exactly. I don't use my graph database 24/7, so 57 | it doesn't make sense to pay for an always-on VM-equivalent. 58 | 59 | (NEPTUNE grows thoughtful) 60 | 61 | So Grand wraps DynamoDB with a graph-based API so that you 62 | can treat data stored in DynamoDB like any other graph. 63 | 64 | NEPTUNE 65 | 66 | Ah, I see thine logic. 67 | 68 | (...Detecting a potential weakness!) 69 | 70 | But havest thou a standardized API?! 71 | 72 | JORDAN 73 | 74 | Aye— I mean, yes. Grand supports interacting with a graph 75 | with standard NetworkX or IGraph APIs, among others. 76 | 77 | NEPTUNE 78 | 79 | (Washing gradually back 80 | into the ocean, calmed... 81 | SUN appears over the 82 | SERVER RACKS) 83 | 84 | I am humbled. Thou mayest proceed. 85 | ``` 86 | -------------------------------------------------------------------------------- /docs/What-About-Neptune.md: -------------------------------------------------------------------------------- 1 | # What about Neptune? 2 | 3 | ## Background 4 | 5 | In many industry applications, graph databases are commonly used for "shallow" graph queries — in other words, queries that rely heavily on node-wise or edge-wise indexing, but not too heavily on multiple hops along graph relations. For example, "find all purchases made by this set of users," or "find all movies with actors that also acted in a movie this user has rated five stars." These queries benefit greatly from the graph structure of the database, but do not involve deep or complete graph traversals. 6 | 7 | In contrast with these common industry needs, mathematical or scientific graph algorithms are notoriously hard to profile, and predicting workload for arbitrary data science manipulations on a graph is a major challenge in many big-data graph or network-science applications. Graph queries may frequently involve traversing every node or edge in a graph, or performing some accumulative function across a graph. So-called "deep" graph queries include subgraph isomorphism search, graph matching, and pathfinding. 8 | 9 | ## AWS Neptune 10 | 11 | [AWS Neptune](https://aws.amazon.com/neptune/getting-started/) is a graph database as a service provided by Amazon Web Services. Though it says "serverless" on the packaging, there are a few considerations to be aware of: 12 | 13 | * Neptune requires that you provision a "server-equivalent" amount of compute power. For example, you can provision a single EC2 instance's worth of compute, in which case it is equivalent to running a graph database on a single node for writes. ([Reads can be parallelized across a provisioned cluster.](https://docs.aws.amazon.com/neptune/latest/userguide/intro.html)) 14 | * Unlike DynamoDB, Neptune does not have an on-demand auto-scaling feature. In other words, if you rapidly double the number of queries you're running per second in DynamoDB, it meets your need. If you rapidly double the number of queries you send to Neptune, it may very likely fail. [[DynamoDB Auto-Scaling Documentation](https://docs.aws.amazon.com/amazondynamodb/latest/developerguide/AutoScaling.html)] 15 | 16 | This means for certain sparse or bursty use-cases, Neptune may be _dramatically_ more expensive. For example, consider these cases: 17 | 18 | ### Neptune's pricing model excels: 19 | 20 | * Homogenous or predictable workload, consistent algorithmic complexity at all hours. 21 | * For example: An e-commerce site that maps users to recommended products. 22 | * Adiabatic (gradual) changes to server load that take place over many minutes or hours (to give auto-scaling software time to adapt) 23 | * For example: A social media website with traffic patterns that match common wake/sleep cycles across time-zones 24 | 25 | ### Neptune's pricing model may be disadvantageous: 26 | * A large workload runs periodically but infrequently, for short periods of time 27 | * For example: A web-crawler that runs every hour to identify and link new pharmaceutical research literature 28 | * A user can trigger an arbitrary graph database algorithm via API 29 | * For example: Users have direct control over the executiion of complex queries across a graph 30 | 31 | As a workaround, [Grand](https://github.com/aplbrain/grand) rewrites graph operations in an abstracted graph API representation, and then implements these calls as operations on a DynamoDB table. In other words, Grand acts as an abstraction layer to enable bursty workloads with arbitrary datastore backends (DynamoDB, SQLite, NetworkX in memory). This is _less efficient_ than using a graph database like Neptune, but it frees the user to pay only for the compute and resources that they are using. 32 | 33 | ## Discussion 34 | 35 | Neptune is still a relatively young product, and I'm hopeful that AWS will consider adding "true-serverless" pricing models, as they have done with "on-demand" DynamoDB pricing and, more recently, Aurora (though [this is still in preview](https://pages.awscloud.com/AmazonAuroraServerlessv2Preview.html)). 36 | 37 | In the medium-term, it is possible to engineer your own scaling software for Neptune that listens for traffic and scales up to meet demand. This capability is currently out of scope for the Grand library. 38 | 39 | --- 40 | 41 | To read a dramatic screenplay retelling of this document, click [here](What-About-Neptune-Dramatic-Retelling.md). 42 | -------------------------------------------------------------------------------- /docs/grand.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aplbrain/grand/ce815357e73c05c947b1194220f531430e6fc2cc/docs/grand.png -------------------------------------------------------------------------------- /docs/reference/backends/_dataframe.py.md: -------------------------------------------------------------------------------- 1 | ## *Function* `is_directed(self) -> bool` 2 | 3 | 4 | Return True if the backend graph is directed. 5 | 6 | ### Arguments 7 | None 8 | 9 | ### Returns 10 | > - **bool** (`None`: `None`): True if the backend graph is directed. 11 | 12 | 13 | 14 | ## *Function* `teardown(self, yes_i_am_sure: bool = False)` 15 | 16 | 17 | Tear down this graph, deleting all evidence it once was here. 18 | 19 | 20 | 21 | ## *Function* `add_node(self, node_name: Hashable, metadata: dict) -> Hashable` 22 | 23 | 24 | Add a new node to the graph. 25 | 26 | Insert a new document into the nodes table. 27 | 28 | ### Arguments 29 | > - **node_name** (`Hashable`: `None`): The ID of the node 30 | > - **metadata** (`dict`: `None`): An optional dictionary of metadata 31 | 32 | ### Returns 33 | > - **Hashable** (`None`: `None`): The ID of this node, as inserted 34 | 35 | 36 | 37 | ## *Function* `all_nodes_as_iterable(self, include_metadata: bool = False)` 38 | 39 | 40 | Get a generator of all of the nodes in this graph. 41 | 42 | ### Arguments 43 | > - **include_metadata** (`bool`: `False`): Whether to include node metadata in 44 | the response 45 | 46 | ### Returns 47 | > - **Generator** (`None`: `None`): A generator of all nodes (arbitrary sort) 48 | 49 | 50 | 51 | ## *Function* `has_node(self, u: Hashable) -> bool` 52 | 53 | 54 | Return true if the node exists in the graph. 55 | 56 | ### Arguments 57 | > - **u** (`Hashable`: `None`): The ID of the node to check 58 | 59 | ### Returns 60 | > - **bool** (`None`: `None`): True if the node exists 61 | 62 | 63 | ## *Function* `add_edge(self, u: Hashable, v: Hashable, metadata: dict)` 64 | 65 | 66 | Add a new edge to the graph between two nodes. 67 | 68 | If the graph is directed, this edge will start (source) at the `u` node and end (target) at the `v` node. 69 | 70 | ### Arguments 71 | > - **u** (`Hashable`: `None`): The source node ID 72 | > - **v** (`Hashable`: `None`): The target node ID 73 | > - **metadata** (`dict`: `None`): Optional metadata to associate with the edge 74 | 75 | ### Returns 76 | > - **Hashable** (`None`: `None`): The edge ID, as inserted. 77 | 78 | 79 | 80 | ## *Function* `_has_edge(self, u: Hashable, v: Hashable) -> bool` 81 | 82 | 83 | Return true if the edge exists in the graph. 84 | 85 | ### Arguments 86 | > - **u** (`Hashable`: `None`): The source node ID 87 | > - **v** (`Hashable`: `None`): The target node ID 88 | 89 | ### Returns 90 | > - **bool** (`None`: `None`): True if the edge exists 91 | 92 | 93 | ## *Function* `all_edges_as_iterable(self, include_metadata: bool = False) -> Generator` 94 | 95 | 96 | Get a list of all edges in this graph, arbitrary sort. 97 | 98 | ### Arguments 99 | > - **include_metadata** (`bool`: `False`): Whether to include edge metadata 100 | 101 | ### Returns 102 | > - **Generator** (`None`: `None`): A generator of all edges (arbitrary sort) 103 | 104 | 105 | 106 | ## *Function* `get_node_by_id(self, node_name: Hashable)` 107 | 108 | 109 | Return the data associated with a node. 110 | 111 | ### Arguments 112 | > - **node_name** (`Hashable`: `None`): The node ID to look up 113 | 114 | ### Returns 115 | > - **dict** (`None`: `None`): The metadata associated with this node 116 | 117 | 118 | 119 | ## *Function* `get_edge_by_id(self, u: Hashable, v: Hashable)` 120 | 121 | 122 | Get an edge by its source and target IDs. 123 | 124 | ### Arguments 125 | > - **u** (`Hashable`: `None`): The source node ID 126 | > - **v** (`Hashable`: `None`): The target node ID 127 | 128 | ### Returns 129 | > - **dict** (`None`: `None`): Metadata associated with this edge 130 | 131 | 132 | 133 | ## *Function* `get_node_neighbors(self, u: Hashable, include_metadata: bool = False)` 134 | 135 | 136 | Get a generator of all downstream nodes from this node. 137 | 138 | ### Arguments 139 | > - **u** (`Hashable`: `None`): The source node ID 140 | 141 | ### Returns 142 | Generator 143 | 144 | 145 | 146 | ## *Function* `_edge_as_dict(self, row)` 147 | 148 | 149 | Convert an edge row to a dictionary. 150 | 151 | ### Arguments 152 | > - **row** (`pandas.Series`: `None`): The edge row 153 | 154 | ### Returns 155 | > - **dict** (`None`: `None`): The edge metadata 156 | 157 | 158 | 159 | ## *Function* `get_node_predecessors(self, u: Hashable, include_metadata: bool = False)` 160 | 161 | 162 | Get a generator of all upstream nodes from this node. 163 | 164 | ### Arguments 165 | > - **u** (`Hashable`: `None`): The source node ID 166 | 167 | ### Returns 168 | Generator 169 | 170 | 171 | 172 | ## *Function* `get_node_count(self) -> int` 173 | 174 | 175 | Get an integer count of the number of nodes in this graph. 176 | 177 | ### Arguments 178 | None 179 | 180 | ### Returns 181 | > - **int** (`None`: `None`): The count of nodes 182 | 183 | -------------------------------------------------------------------------------- /docs/reference/backends/_dynamodb.py.md: -------------------------------------------------------------------------------- 1 | ## *Function* `_dynamo_table_exists(table_name: str, client: boto3.client)` 2 | 3 | 4 | Check to see if the DynamoDB table already exists. 5 | 6 | ### Returns 7 | > - **bool** (`None`: `None`): Whether table exists 8 | 9 | 10 | 11 | ## *Class* `DynamoDBBackend(Backend)` 12 | 13 | 14 | A graph datastore that uses DynamoDB for persistance and queries. 15 | 16 | 17 | 18 | ## *Function* `is_directed(self) -> bool` 19 | 20 | 21 | Return True if the backend graph is directed. 22 | 23 | ### Arguments 24 | None 25 | 26 | ### Returns 27 | > - **bool** (`None`: `None`): True if the backend graph is directed. 28 | 29 | 30 | 31 | ## *Function* `teardown(self, yes_i_am_sure: bool = False)` 32 | 33 | 34 | Tear down this graph, deleting all evidence it once was here. 35 | 36 | 37 | 38 | ## *Function* `add_node(self, node_name: Hashable, metadata: dict) -> Hashable` 39 | 40 | 41 | Add a new node to the graph. 42 | 43 | Insert a new document into the nodes table. 44 | 45 | ### Arguments 46 | > - **node_name** (`Hashable`: `None`): The ID of the node 47 | > - **metadata** (`dict`: `None`): An optional dictionary of metadata 48 | 49 | ### Returns 50 | > - **Hashable** (`None`: `None`): The ID of this node, as inserted 51 | 52 | 53 | 54 | ## *Function* `all_nodes_as_iterable(self, include_metadata: bool = False) -> Collection` 55 | 56 | 57 | Get a generator of all of the nodes in this graph. 58 | 59 | ### Arguments 60 | > - **include_metadata** (`bool`: `False`): Whether to include node metadata in 61 | the response 62 | 63 | ### Returns 64 | > - **Generator** (`None`: `None`): A generator of all nodes (arbitrary sort) 65 | 66 | 67 | 68 | ## *Function* `has_node(self, u: Hashable) -> bool` 69 | 70 | 71 | Return true if the node exists in the graph. 72 | 73 | ### Arguments 74 | > - **u** (`Hashable`: `None`): The ID of the node to check 75 | 76 | ### Returns 77 | > - **bool** (`None`: `None`): True if the node exists 78 | 79 | 80 | ## *Function* `add_edge(self, u: Hashable, v: Hashable, metadata: dict)` 81 | 82 | 83 | Add a new edge to the graph between two nodes. 84 | 85 | If the graph is directed, this edge will start (source) at the `u` node and end (target) at the `v` node. 86 | 87 | ### Arguments 88 | > - **u** (`Hashable`: `None`): The source node ID 89 | > - **v** (`Hashable`: `None`): The target node ID 90 | > - **metadata** (`dict`: `None`): Optional metadata to associate with the edge 91 | 92 | ### Returns 93 | > - **Hashable** (`None`: `None`): The edge ID, as inserted. 94 | 95 | 96 | 97 | ## *Function* `all_edges_as_iterable(self, include_metadata: bool = False) -> Collection` 98 | 99 | 100 | Get a list of all edges in this graph, arbitrary sort. 101 | 102 | ### Arguments 103 | > - **include_metadata** (`bool`: `False`): Whether to include edge metadata 104 | 105 | ### Returns 106 | > - **Generator** (`None`: `None`): A generator of all edges (arbitrary sort) 107 | 108 | 109 | 110 | ## *Function* `get_node_by_id(self, node_name: Hashable)` 111 | 112 | 113 | Return the data associated with a node. 114 | 115 | ### Arguments 116 | > - **node_name** (`Hashable`: `None`): The node ID to look up 117 | 118 | ### Returns 119 | > - **dict** (`None`: `None`): The metadata associated with this node 120 | 121 | 122 | 123 | ## *Function* `get_edge_by_id(self, u: Hashable, v: Hashable)` 124 | 125 | 126 | Get an edge by its source and target IDs. 127 | 128 | ### Arguments 129 | > - **u** (`Hashable`: `None`): The source node ID 130 | > - **v** (`Hashable`: `None`): The target node ID 131 | 132 | ### Returns 133 | > - **dict** (`None`: `None`): Metadata associated with this edge 134 | 135 | 136 | 137 | ## *Function* `get_node_count(self) -> int` 138 | 139 | 140 | Get an integer count of the number of nodes in this graph. 141 | 142 | ### Arguments 143 | None 144 | 145 | ### Returns 146 | > - **int** (`None`: `None`): The count of nodes 147 | 148 | -------------------------------------------------------------------------------- /docs/reference/backends/_gremlin.py.md: -------------------------------------------------------------------------------- 1 | ## *Class* `GremlinBackend(Backend)` 2 | 3 | 4 | A backend instance for Gremlin-compatible graph databases. 5 | 6 | 7 | 8 | ## *Function* `__init__(self, graph: GraphTraversalSource, directed: bool = True)` 9 | 10 | 11 | Create a new Backend instance wrapping a Gremlin endpoint. 12 | 13 | ### Arguments 14 | > - **directed** (`bool`: `False`): Whether to make the backend graph directed 15 | 16 | ### Returns 17 | None 18 | 19 | 20 | 21 | ## *Function* `is_directed(self) -> bool` 22 | 23 | 24 | Return True if the backend graph is directed. 25 | 26 | The Gremlin-backed datastore is always directed. 27 | 28 | ### Arguments 29 | None 30 | 31 | ### Returns 32 | > - **bool** (`None`: `None`): True if the backend graph is directed. 33 | 34 | 35 | 36 | ## *Function* `add_node(self, node_name: Hashable, metadata: dict)` 37 | 38 | 39 | Add a new node to the graph. 40 | 41 | ### Arguments 42 | > - **node_name** (`Hashable`: `None`): The ID of the node 43 | > - **metadata** (`dict`: `None`): An optional dictionary of metadata 44 | 45 | ### Returns 46 | > - **Hashable** (`None`: `None`): The ID of this node, as inserted 47 | 48 | 49 | 50 | ## *Function* `get_node_by_id(self, node_name: Hashable)` 51 | 52 | 53 | Return the data associated with a node. 54 | 55 | ### Arguments 56 | > - **node_name** (`Hashable`: `None`): The node ID to look up 57 | 58 | ### Returns 59 | > - **dict** (`None`: `None`): The metadata associated with this node 60 | 61 | 62 | 63 | ## *Function* `has_node(self, u: Hashable) -> bool` 64 | 65 | 66 | Return the data associated with a node. 67 | 68 | ### Arguments 69 | > - **node_name** (`Hashable`: `None`): The node ID to look up 70 | 71 | ### Returns 72 | > - **dict** (`None`: `None`): The metadata associated with this node 73 | 74 | 75 | 76 | ## *Function* `remove_node(self, node_name: Hashable)` 77 | 78 | 79 | Remove a node. 80 | 81 | ### Arguments 82 | > - **node_name** (`Hashable`: `None`): The node ID to look up 83 | 84 | ### Returns 85 | > - **dict** (`None`: `None`): The metadata associated with this node 86 | 87 | 88 | 89 | ## *Function* `all_nodes_as_iterable(self, include_metadata: bool = False) -> Collection` 90 | 91 | 92 | Get a generator of all of the nodes in this graph. 93 | 94 | ### Arguments 95 | > - **include_metadata** (`bool`: `False`): Whether to include node metadata in 96 | the response 97 | 98 | ### Returns 99 | > - **Generator** (`None`: `None`): A generator of all nodes (arbitrary sort) 100 | 101 | 102 | 103 | ## *Function* `add_edge(self, u: Hashable, v: Hashable, metadata: dict)` 104 | 105 | 106 | Add a new edge to the graph between two nodes. 107 | 108 | If the graph is directed, this edge will start (source) at the `u` node and end (target) at the `v` node. 109 | 110 | ### Arguments 111 | > - **u** (`Hashable`: `None`): The source node ID 112 | > - **v** (`Hashable`: `None`): The target node ID 113 | > - **metadata** (`dict`: `None`): Optional metadata to associate with the edge 114 | 115 | ### Returns 116 | > - **Hashable** (`None`: `None`): The edge ID, as inserted. 117 | 118 | 119 | 120 | ## *Function* `all_edges_as_iterable(self, include_metadata: bool = False) -> Collection` 121 | 122 | 123 | Get a list of all edges in this graph, arbitrary sort. 124 | 125 | ### Arguments 126 | > - **include_metadata** (`bool`: `False`): Whether to include edge metadata 127 | 128 | ### Returns 129 | > - **Generator** (`None`: `None`): A generator of all edges (arbitrary sort) 130 | 131 | 132 | 133 | ## *Function* `get_edge_by_id(self, u: Hashable, v: Hashable)` 134 | 135 | 136 | Get an edge by its source and target IDs. 137 | 138 | ### Arguments 139 | > - **u** (`Hashable`: `None`): The source node ID 140 | > - **v** (`Hashable`: `None`): The target node ID 141 | 142 | ### Returns 143 | > - **dict** (`None`: `None`): Metadata associated with this edge 144 | 145 | 146 | 147 | ## *Function* `get_node_count(self) -> int` 148 | 149 | 150 | Get an integer count of the number of nodes in this graph. 151 | 152 | ### Arguments 153 | None 154 | 155 | ### Returns 156 | > - **int** (`None`: `None`): The count of nodes 157 | 158 | -------------------------------------------------------------------------------- /docs/reference/backends/_igraph.py.md: -------------------------------------------------------------------------------- 1 | ## *Class* `IGraphBackend(Backend)` 2 | 3 | 4 | This is currently UNOPTIMIZED CODE. 5 | 6 | Recommendations for future work include improved indexing and caching of node names and metadata. 7 | 8 | 9 | 10 | ## *Function* `__init__(self, directed: bool = False)` 11 | 12 | 13 | Create a new IGraphBackend instance, using an igraph.Graph object to store and manage network structure. 14 | 15 | ### Arguments 16 | > - **directed** (`bool`: `False`): Whether to make the backend graph directed 17 | 18 | ### Returns 19 | None 20 | 21 | 22 | 23 | ## *Function* `is_directed(self) -> bool` 24 | 25 | 26 | Return True if the backend graph is directed. 27 | 28 | ### Arguments 29 | None 30 | 31 | ### Returns 32 | > - **bool** (`None`: `None`): True if the backend graph is directed. 33 | 34 | 35 | 36 | ## *Function* `add_node(self, node_name: Hashable, metadata: dict)` 37 | 38 | 39 | Add a new node to the graph. 40 | 41 | ### Arguments 42 | > - **node_name** (`Hashable`: `None`): The ID of the node 43 | > - **metadata** (`dict`: `None`): An optional dictionary of metadata 44 | 45 | ### Returns 46 | > - **Hashable** (`None`: `None`): The ID of this node, as inserted 47 | 48 | 49 | 50 | ## *Function* `get_node_by_id(self, node_name: Hashable)` 51 | 52 | 53 | Return the data associated with a node. 54 | 55 | ### Arguments 56 | > - **node_name** (`Hashable`: `None`): The node ID to look up 57 | 58 | ### Returns 59 | > - **dict** (`None`: `None`): The metadata associated with this node 60 | 61 | 62 | 63 | ## *Function* `all_nodes_as_iterable(self, include_metadata: bool = False) -> Collection` 64 | 65 | 66 | Get a generator of all of the nodes in this graph. 67 | 68 | ### Arguments 69 | > - **include_metadata** (`bool`: `False`): Whether to include node metadata in 70 | the response 71 | 72 | ### Returns 73 | > - **Generator** (`None`: `None`): A generator of all nodes (arbitrary sort) 74 | 75 | 76 | 77 | ## *Function* `has_node(self, u: Hashable) -> bool` 78 | 79 | 80 | Return true if the node exists in the graph. 81 | 82 | ### Arguments 83 | > - **u** (`Hashable`: `None`): The ID of the node to check 84 | 85 | ### Returns 86 | > - **bool** (`None`: `None`): True if the node exists 87 | 88 | 89 | ## *Function* `add_edge(self, u: Hashable, v: Hashable, metadata: dict)` 90 | 91 | 92 | Add a new edge to the graph between two nodes. 93 | 94 | If the graph is directed, this edge will start (source) at the `u` node and end (target) at the `v` node. 95 | 96 | ### Arguments 97 | > - **u** (`Hashable`: `None`): The source node ID 98 | > - **v** (`Hashable`: `None`): The target node ID 99 | > - **metadata** (`dict`: `None`): Optional metadata to associate with the edge 100 | 101 | ### Returns 102 | > - **Hashable** (`None`: `None`): The edge ID, as inserted. 103 | 104 | 105 | 106 | ## *Function* `all_edges_as_iterable(self, include_metadata: bool = False) -> Collection` 107 | 108 | 109 | Get a list of all edges in this graph, arbitrary sort. 110 | 111 | ### Arguments 112 | > - **include_metadata** (`bool`: `False`): Whether to include edge metadata 113 | 114 | ### Returns 115 | > - **Generator** (`None`: `None`): A generator of all edges (arbitrary sort) 116 | 117 | 118 | 119 | ## *Function* `get_edge_by_id(self, u: Hashable, v: Hashable)` 120 | 121 | 122 | Get an edge by its source and target IDs. 123 | 124 | ### Arguments 125 | > - **u** (`Hashable`: `None`): The source node ID 126 | > - **v** (`Hashable`: `None`): The target node ID 127 | 128 | ### Returns 129 | > - **dict** (`None`: `None`): Metadata associated with this edge 130 | 131 | 132 | 133 | ## *Function* `get_node_count(self) -> int` 134 | 135 | 136 | Get an integer count of the number of nodes in this graph. 137 | 138 | ### Arguments 139 | None 140 | 141 | ### Returns 142 | > - **int** (`None`: `None`): The count of nodes 143 | 144 | -------------------------------------------------------------------------------- /docs/reference/backends/_networkit.py.md: -------------------------------------------------------------------------------- 1 | ## *Class* `NetworkitBackend(Backend)` 2 | 3 | 4 | Networkit doesn't support metadata or named nodes, so all node names and metadata must currently be stored in a parallel data structure. 5 | 6 | To solve this problem, a NodeNameManager and MetadataStore, from `grand.backends.metadatastore.NodeNameManager` and `grand.backends.metadatastore.MetadataStore` respectively, are included at the top level of this class. In order to preserve this metadata structure statefully, you must serialize both the graph as well as the data stores. 7 | 8 | This is currently UNOPTIMIZED CODE. 9 | 10 | Recommendations for future work include improved indexing and caching of node names and metadata. 11 | 12 | > - **documentation** (`None`: `None`): https://networkit.github.io/dev-docs/python_api/graph.html 13 | 14 | 15 | 16 | ## *Function* `__init__(self, directed: bool = False, metadata_store: MetadataStore = None)` 17 | 18 | 19 | Create a new NetworkitBackend instance, using a Networkit.graph.Graph object to store and manage network structure. 20 | 21 | ### Arguments 22 | > - **directed** (`bool`: `False`): Whether to make the backend graph directed 23 | > - **metadata_store** (`MetadataStore`: `None`): Optionally, a MetadataStore to use 24 | to handle node and edge attributes. If not provided, defaults to a DictMetadataStore. 25 | 26 | ### Returns 27 | None 28 | 29 | 30 | 31 | ## *Function* `is_directed(self) -> bool` 32 | 33 | 34 | Return True if the backend graph is directed. 35 | 36 | ### Arguments 37 | None 38 | 39 | ### Returns 40 | > - **bool** (`None`: `None`): True if the backend graph is directed. 41 | 42 | 43 | 44 | ## *Function* `add_node(self, node_name: Hashable, metadata: dict)` 45 | 46 | 47 | Add a new node to the graph. 48 | 49 | ### Arguments 50 | > - **node_name** (`Hashable`: `None`): The ID of the node 51 | > - **metadata** (`dict`: `None`): An optional dictionary of metadata 52 | 53 | ### Returns 54 | > - **Hashable** (`None`: `None`): The ID of this node, as inserted 55 | 56 | 57 | 58 | ## *Function* `get_node_by_id(self, node_name: Hashable)` 59 | 60 | 61 | Return the data associated with a node. 62 | 63 | ### Arguments 64 | > - **node_name** (`Hashable`: `None`): The node ID to look up 65 | 66 | ### Returns 67 | > - **dict** (`None`: `None`): The metadata associated with this node 68 | 69 | 70 | 71 | ## *Function* `all_nodes_as_iterable(self, include_metadata: bool = False) -> Generator` 72 | 73 | 74 | Get a generator of all of the nodes in this graph. 75 | 76 | ### Arguments 77 | > - **include_metadata** (`bool`: `False`): Whether to include node metadata in 78 | the response 79 | 80 | ### Returns 81 | > - **Generator** (`None`: `None`): A generator of all nodes (arbitrary sort) 82 | 83 | 84 | 85 | ## *Function* `has_node(self, u: Hashable) -> bool` 86 | 87 | 88 | Return true if the node exists in the graph. 89 | 90 | ### Arguments 91 | > - **u** (`Hashable`: `None`): The ID of the node to check 92 | 93 | ### Returns 94 | > - **bool** (`None`: `None`): True if the node exists 95 | 96 | 97 | ## *Function* `add_edge(self, u: Hashable, v: Hashable, metadata: dict)` 98 | 99 | 100 | Add a new edge to the graph between two nodes. 101 | 102 | If the graph is directed, this edge will start (source) at the `u` node and end (target) at the `v` node. 103 | 104 | ### Arguments 105 | > - **u** (`Hashable`: `None`): The source node ID 106 | > - **v** (`Hashable`: `None`): The target node ID 107 | > - **metadata** (`dict`: `None`): Optional metadata to associate with the edge 108 | 109 | ### Returns 110 | > - **Hashable** (`None`: `None`): The edge ID, as inserted. 111 | 112 | 113 | 114 | ## *Function* `all_edges_as_iterable(self, include_metadata: bool = False) -> Generator` 115 | 116 | 117 | Get a list of all edges in this graph, arbitrary sort. 118 | 119 | ### Arguments 120 | > - **include_metadata** (`bool`: `False`): Whether to include edge metadata 121 | 122 | ### Returns 123 | > - **Generator** (`None`: `None`): A generator of all edges (arbitrary sort) 124 | 125 | 126 | 127 | ## *Function* `get_edge_by_id(self, u: Hashable, v: Hashable)` 128 | 129 | 130 | Get an edge by its source and target IDs. 131 | 132 | ### Arguments 133 | > - **u** (`Hashable`: `None`): The source node ID 134 | > - **v** (`Hashable`: `None`): The target node ID 135 | 136 | ### Returns 137 | > - **dict** (`None`: `None`): Metadata associated with this edge 138 | 139 | 140 | 141 | ## *Function* `get_node_count(self) -> Iterable` 142 | 143 | 144 | Get an integer count of the number of nodes in this graph. 145 | 146 | ### Arguments 147 | None 148 | 149 | ### Returns 150 | > - **int** (`None`: `None`): The count of nodes 151 | 152 | -------------------------------------------------------------------------------- /docs/reference/backends/_networkx.py.md: -------------------------------------------------------------------------------- 1 | ## *Function* `__init__(self, directed: bool = False)` 2 | 3 | 4 | Create a new Backend instance. 5 | 6 | ### Arguments 7 | > - **directed** (`bool`: `False`): Whether to make the backend graph directed 8 | 9 | ### Returns 10 | None 11 | 12 | 13 | 14 | ## *Function* `is_directed(self) -> bool` 15 | 16 | 17 | Return True if the backend graph is directed. 18 | 19 | ### Arguments 20 | None 21 | 22 | ### Returns 23 | > - **bool** (`None`: `None`): True if the backend graph is directed. 24 | 25 | 26 | 27 | ## *Function* `add_node(self, node_name: Hashable, metadata: dict)` 28 | 29 | 30 | Add a new node to the graph. 31 | 32 | ### Arguments 33 | > - **node_name** (`Hashable`: `None`): The ID of the node 34 | > - **metadata** (`dict`: `None`): An optional dictionary of metadata 35 | 36 | ### Returns 37 | > - **Hashable** (`None`: `None`): The ID of this node, as inserted 38 | 39 | 40 | 41 | ## *Function* `get_node_by_id(self, node_name: Hashable)` 42 | 43 | 44 | Return the data associated with a node. 45 | 46 | ### Arguments 47 | > - **node_name** (`Hashable`: `None`): The node ID to look up 48 | 49 | ### Returns 50 | > - **dict** (`None`: `None`): The metadata associated with this node 51 | 52 | 53 | 54 | ## *Function* `all_nodes_as_iterable(self, include_metadata: bool = False) -> Collection` 55 | 56 | 57 | Get a generator of all of the nodes in this graph. 58 | 59 | ### Arguments 60 | > - **include_metadata** (`bool`: `False`): Whether to include node metadata in 61 | the response 62 | 63 | ### Returns 64 | > - **Generator** (`None`: `None`): A generator of all nodes (arbitrary sort) 65 | 66 | 67 | 68 | ## *Function* `add_edge(self, u: Hashable, v: Hashable, metadata: dict)` 69 | 70 | 71 | Add a new edge to the graph between two nodes. 72 | 73 | If the graph is directed, this edge will start (source) at the `u` node and end (target) at the `v` node. 74 | 75 | ### Arguments 76 | > - **u** (`Hashable`: `None`): The source node ID 77 | > - **v** (`Hashable`: `None`): The target node ID 78 | > - **metadata** (`dict`: `None`): Optional metadata to associate with the edge 79 | 80 | ### Returns 81 | > - **Hashable** (`None`: `None`): The edge ID, as inserted. 82 | 83 | 84 | 85 | ## *Function* `all_edges_as_iterable(self, include_metadata: bool = False) -> Collection` 86 | 87 | 88 | Get a list of all edges in this graph, arbitrary sort. 89 | 90 | ### Arguments 91 | > - **include_metadata** (`bool`: `False`): Whether to include edge metadata 92 | 93 | ### Returns 94 | > - **Generator** (`None`: `None`): A generator of all edges (arbitrary sort) 95 | 96 | 97 | 98 | ## *Function* `get_edge_by_id(self, u: Hashable, v: Hashable)` 99 | 100 | 101 | Get an edge by its source and target IDs. 102 | 103 | ### Arguments 104 | > - **u** (`Hashable`: `None`): The source node ID 105 | > - **v** (`Hashable`: `None`): The target node ID 106 | 107 | ### Returns 108 | > - **dict** (`None`: `None`): Metadata associated with this edge 109 | 110 | 111 | 112 | ## *Function* `get_node_count(self) -> int` 113 | 114 | 115 | Get an integer count of the number of nodes in this graph. 116 | 117 | ### Arguments 118 | None 119 | 120 | ### Returns 121 | > - **int** (`None`: `None`): The count of nodes 122 | 123 | -------------------------------------------------------------------------------- /docs/reference/backends/_sqlbackend.py.md: -------------------------------------------------------------------------------- 1 | ## *Class* `SQLBackend(Backend)` 2 | 3 | 4 | A graph datastore that uses a SQL-like store for persistance and queries. 5 | 6 | 7 | 8 | ## *Function* `is_directed(self) -> bool` 9 | 10 | 11 | Return True if the backend graph is directed. 12 | 13 | ### Arguments 14 | None 15 | 16 | ### Returns 17 | > - **bool** (`None`: `None`): True if the backend graph is directed. 18 | 19 | 20 | 21 | ## *Function* `teardown(self, yes_i_am_sure: bool = False)` 22 | 23 | 24 | Tear down this graph, deleting all evidence it once was here. 25 | 26 | 27 | 28 | ## *Function* `add_node(self, node_name: Hashable, metadata: dict) -> Hashable` 29 | 30 | 31 | Add a new node to the graph. 32 | 33 | Insert a new document into the nodes table. 34 | 35 | ### Arguments 36 | > - **node_name** (`Hashable`: `None`): The ID of the node 37 | > - **metadata** (`dict`: `None`): An optional dictionary of metadata 38 | 39 | ### Returns 40 | > - **Hashable** (`None`: `None`): The ID of this node, as inserted 41 | 42 | 43 | 44 | ## *Function* `_upsert_node(self, node_name: Hashable, metadata: dict) -> Hashable` 45 | 46 | 47 | Add a new node to the graph, or update an existing one. 48 | 49 | ### Arguments 50 | > - **node_name** (`Hashable`: `None`): The ID of the node 51 | > - **metadata** (`dict`: `None`): An optional dictionary of metadata 52 | 53 | ### Returns 54 | > - **Hashable** (`None`: `None`): The ID of this node, as inserted 55 | 56 | 57 | 58 | ## *Function* `all_nodes_as_iterable(self, include_metadata: bool = False) -> Generator` 59 | 60 | 61 | Get a generator of all of the nodes in this graph. 62 | 63 | ### Arguments 64 | > - **include_metadata** (`bool`: `False`): Whether to include node metadata in 65 | the response 66 | 67 | ### Returns 68 | > - **Generator** (`None`: `None`): A generator of all nodes (arbitrary sort) 69 | 70 | 71 | 72 | ## *Function* `has_node(self, u: Hashable) -> bool` 73 | 74 | 75 | Return true if the node exists in the graph. 76 | 77 | ### Arguments 78 | > - **u** (`Hashable`: `None`): The ID of the node to check 79 | 80 | ### Returns 81 | > - **bool** (`None`: `None`): True if the node exists 82 | 83 | 84 | ## *Function* `add_edge(self, u: Hashable, v: Hashable, metadata: dict)` 85 | 86 | 87 | Add a new edge to the graph between two nodes. 88 | 89 | If the graph is directed, this edge will start (source) at the `u` node and end (target) at the `v` node. 90 | 91 | ### Arguments 92 | > - **u** (`Hashable`: `None`): The source node ID 93 | > - **v** (`Hashable`: `None`): The target node ID 94 | > - **metadata** (`dict`: `None`): Optional metadata to associate with the edge 95 | 96 | ### Returns 97 | > - **Hashable** (`None`: `None`): The edge ID, as inserted. 98 | 99 | 100 | 101 | ## *Function* `all_edges_as_iterable(self, include_metadata: bool = False) -> Generator` 102 | 103 | 104 | Get a list of all edges in this graph, arbitrary sort. 105 | 106 | ### Arguments 107 | > - **include_metadata** (`bool`: `False`): Whether to include edge metadata 108 | 109 | ### Returns 110 | > - **Generator** (`None`: `None`): A generator of all edges (arbitrary sort) 111 | 112 | 113 | 114 | ## *Function* `get_node_by_id(self, node_name: Hashable)` 115 | 116 | 117 | Return the data associated with a node. 118 | 119 | ### Arguments 120 | > - **node_name** (`Hashable`: `None`): The node ID to look up 121 | 122 | ### Returns 123 | > - **dict** (`None`: `None`): The metadata associated with this node 124 | 125 | 126 | 127 | ## *Function* `get_edge_by_id(self, u: Hashable, v: Hashable)` 128 | 129 | 130 | Get an edge by its source and target IDs. 131 | 132 | ### Arguments 133 | > - **u** (`Hashable`: `None`): The source node ID 134 | > - **v** (`Hashable`: `None`): The target node ID 135 | 136 | ### Returns 137 | > - **dict** (`None`: `None`): Metadata associated with this edge 138 | 139 | 140 | 141 | ## *Function* `get_node_count(self) -> Iterable` 142 | 143 | 144 | Get an integer count of the number of nodes in this graph. 145 | 146 | ### Arguments 147 | None 148 | 149 | ### Returns 150 | > - **int** (`None`: `None`): The count of nodes 151 | 152 | 153 | 154 | ## *Function* `out_degrees(self, nbunch=None)` 155 | 156 | 157 | Return the in-degree of each node in the graph. 158 | 159 | ### Arguments 160 | > - **nbunch** (`Iterable`: `None`): The nodes to get the in-degree of 161 | 162 | ### Returns 163 | > - **dict** (`None`: `None`): A dictionary of node: in-degree pairs 164 | 165 | 166 | 167 | ## *Function* `in_degrees(self, nbunch=None)` 168 | 169 | 170 | Return the in-degree of each node in the graph. 171 | 172 | ### Arguments 173 | > - **nbunch** (`Iterable`: `None`): The nodes to get the in-degree of 174 | 175 | ### Returns 176 | > - **dict** (`None`: `None`): A dictionary of node: in-degree pairs 177 | 178 | -------------------------------------------------------------------------------- /docs/reference/backends/backend.py.md: -------------------------------------------------------------------------------- 1 | ## *Class* `Backend(abc.ABC)` 2 | 3 | 4 | Abstract base class for the management of persisted graph structure. 5 | 6 | Do not use this class directly. 7 | 8 | 9 | 10 | ## *Function* `__init__(self, directed: bool = False)` 11 | 12 | 13 | Create a new Backend instance. 14 | 15 | ### Arguments 16 | > - **directed** (`bool`: `False`): Whether to make the backend graph directed 17 | 18 | ### Returns 19 | None 20 | 21 | 22 | 23 | ## *Function* `is_directed(self) -> bool` 24 | 25 | 26 | Return True if the backend graph is directed. 27 | 28 | ### Arguments 29 | None 30 | 31 | ### Returns 32 | > - **bool** (`None`: `None`): True if the backend graph is directed. 33 | 34 | 35 | 36 | ## *Function* `add_node(self, node_name: Hashable, metadata: dict)` 37 | 38 | 39 | Add a new node to the graph. 40 | 41 | ### Arguments 42 | > - **node_name** (`Hashable`: `None`): The ID of the node 43 | > - **metadata** (`dict`: `None`): An optional dictionary of metadata 44 | > - **upsert** (`bool`: `True`): Update the node if it already exists. If this 45 | is set to False and the node already exists, a backend may choose to throw an error or proceed gracefully. 46 | 47 | ### Returns 48 | > - **Hashable** (`None`: `None`): The ID of this node, as inserted 49 | 50 | 51 | 52 | ## *Function* `get_node_by_id(self, node_name: Hashable)` 53 | 54 | 55 | Return the data associated with a node. 56 | 57 | ### Arguments 58 | > - **node_name** (`Hashable`: `None`): The node ID to look up 59 | 60 | ### Returns 61 | > - **dict** (`None`: `None`): The metadata associated with this node 62 | 63 | 64 | 65 | ## *Function* `all_nodes_as_iterable(self, include_metadata: bool = False) -> Collection` 66 | 67 | 68 | Get a generator of all of the nodes in this graph. 69 | 70 | ### Arguments 71 | > - **include_metadata** (`bool`: `False`): Whether to include node metadata in 72 | the response 73 | 74 | ### Returns 75 | > - **Generator** (`None`: `None`): A generator of all nodes (arbitrary sort) 76 | 77 | 78 | 79 | ## *Function* `has_node(self, u: Hashable) -> bool` 80 | 81 | 82 | Return true if the node exists in the graph. 83 | 84 | ### Arguments 85 | > - **u** (`Hashable`: `None`): The ID of the node to check 86 | 87 | ### Returns 88 | > - **bool** (`None`: `None`): True if the node exists 89 | 90 | 91 | ## *Function* `add_edge(self, u: Hashable, v: Hashable, metadata: dict)` 92 | 93 | 94 | Add a new edge to the graph between two nodes. 95 | 96 | If the graph is directed, this edge will start (source) at the `u` node and end (target) at the `v` node. 97 | 98 | ### Arguments 99 | > - **u** (`Hashable`: `None`): The source node ID 100 | > - **v** (`Hashable`: `None`): The target node ID 101 | > - **metadata** (`dict`: `None`): Optional metadata to associate with the edge 102 | 103 | ### Returns 104 | > - **Hashable** (`None`: `None`): The edge ID, as inserted. 105 | 106 | 107 | 108 | ## *Function* `all_edges_as_iterable(self, include_metadata: bool = False) -> Collection` 109 | 110 | 111 | Get a list of all edges in this graph, arbitrary sort. 112 | 113 | ### Arguments 114 | > - **include_metadata** (`bool`: `False`): Whether to include edge metadata 115 | 116 | ### Returns 117 | > - **Generator** (`None`: `None`): A generator of all edges (arbitrary sort) 118 | 119 | 120 | 121 | ## *Function* `get_edge_by_id(self, u: Hashable, v: Hashable)` 122 | 123 | 124 | Get an edge by its source and target IDs. 125 | 126 | ### Arguments 127 | > - **u** (`Hashable`: `None`): The source node ID 128 | > - **v** (`Hashable`: `None`): The target node ID 129 | 130 | ### Returns 131 | > - **dict** (`None`: `None`): Metadata associated with this edge 132 | 133 | 134 | 135 | ## *Function* `get_node_count(self) -> int` 136 | 137 | 138 | Get an integer count of the number of nodes in this graph. 139 | 140 | ### Arguments 141 | None 142 | 143 | ### Returns 144 | > - **int** (`None`: `None`): The count of nodes 145 | 146 | 147 | 148 | ## *Function* `degree(self, u: Hashable) -> int` 149 | 150 | 151 | Get the degree of a node. 152 | 153 | ### Arguments 154 | > - **u** (`Hashable`: `None`): The node ID 155 | 156 | ### Returns 157 | > - **int** (`None`: `None`): The degree of the node 158 | 159 | 160 | 161 | ## *Function* `in_degree(self, u: Hashable) -> int` 162 | 163 | 164 | Get the in-degree of a node. 165 | 166 | ### Arguments 167 | > - **u** (`Hashable`: `None`): The node ID 168 | 169 | ### Returns 170 | > - **int** (`None`: `None`): The in-degree of the node 171 | 172 | 173 | 174 | ## *Function* `out_degree(self, u: Hashable) -> int` 175 | 176 | 177 | Get the out-degree of a node. 178 | 179 | ### Arguments 180 | > - **u** (`Hashable`: `None`): The node ID 181 | 182 | ### Returns 183 | > - **int** (`None`: `None`): The out-degree of the node 184 | 185 | 186 | 187 | ## *Class* `CachedBackend(Backend)` 188 | 189 | 190 | A proxy Backend that serves as a cache for any other grand.Backend. 191 | 192 | 193 | 194 | ## *Class* `InMemoryCachedBackend(CachedBackend)` 195 | 196 | 197 | A proxy Backend that serves as a cache for any other grand.Backend. 198 | 199 | Wraps each call to the Backend with an LRU cache. 200 | 201 | 202 | 203 | ## *Function* `clear_cache(self)` 204 | 205 | 206 | Clear the cache. 207 | 208 | -------------------------------------------------------------------------------- /docs/reference/backends/backends.md: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aplbrain/grand/ce815357e73c05c947b1194220f531430e6fc2cc/docs/reference/backends/backends.md -------------------------------------------------------------------------------- /docs/reference/backends/dynamodb.py.md: -------------------------------------------------------------------------------- 1 | ## *Function* `_dynamo_table_exists(table_name: str, client: boto3.client)` 2 | 3 | 4 | Check to see if the DynamoDB table already exists. 5 | 6 | ### Returns 7 | > - **bool** (`None`: `None`): Whether table exists 8 | 9 | 10 | 11 | ## *Class* `DynamoDBBackend(Backend)` 12 | 13 | 14 | A graph datastore that uses DynamoDB for persistance and queries. 15 | 16 | 17 | 18 | ## *Function* `is_directed(self) -> bool` 19 | 20 | 21 | Return True if the backend graph is directed. 22 | 23 | ### Arguments 24 | None 25 | 26 | ### Returns 27 | > - **bool** (`None`: `None`): True if the backend graph is directed. 28 | 29 | 30 | 31 | ## *Function* `teardown(self, yes_i_am_sure: bool = False)` 32 | 33 | 34 | Tear down this graph, deleting all evidence it once was here. 35 | 36 | 37 | 38 | ## *Function* `add_node(self, node_name: Hashable, metadata: dict) -> Hashable` 39 | 40 | 41 | Add a new node to the graph. 42 | 43 | Insert a new document into the nodes table. 44 | 45 | ### Arguments 46 | > - **node_name** (`Hashable`: `None`): The ID of the node 47 | > - **metadata** (`dict`: `None`): An optional dictionary of metadata 48 | 49 | ### Returns 50 | > - **Hashable** (`None`: `None`): The ID of this node, as inserted 51 | 52 | 53 | 54 | ## *Function* `all_nodes_as_iterable(self, include_metadata: bool = False) -> Generator` 55 | 56 | 57 | Get a generator of all of the nodes in this graph. 58 | 59 | ### Arguments 60 | > - **include_metadata** (`bool`: `False`): Whether to include node metadata in 61 | the response 62 | 63 | ### Returns 64 | > - **Generator** (`None`: `None`): A generator of all nodes (arbitrary sort) 65 | 66 | 67 | 68 | ## *Function* `has_node(self, u: Hashable) -> bool` 69 | 70 | 71 | Return true if the node exists in the graph. 72 | 73 | ### Arguments 74 | > - **u** (`Hashable`: `None`): The ID of the node to check 75 | 76 | ### Returns 77 | > - **bool** (`None`: `None`): True if the node exists 78 | 79 | 80 | ## *Function* `add_edge(self, u: Hashable, v: Hashable, metadata: dict)` 81 | 82 | 83 | Add a new edge to the graph between two nodes. 84 | 85 | If the graph is directed, this edge will start (source) at the `u` node and end (target) at the `v` node. 86 | 87 | ### Arguments 88 | > - **u** (`Hashable`: `None`): The source node ID 89 | > - **v** (`Hashable`: `None`): The target node ID 90 | > - **metadata** (`dict`: `None`): Optional metadata to associate with the edge 91 | 92 | ### Returns 93 | > - **Hashable** (`None`: `None`): The edge ID, as inserted. 94 | 95 | 96 | 97 | ## *Function* `all_edges_as_iterable(self, include_metadata: bool = False) -> Generator` 98 | 99 | 100 | Get a list of all edges in this graph, arbitrary sort. 101 | 102 | ### Arguments 103 | > - **include_metadata** (`bool`: `False`): Whether to include edge metadata 104 | 105 | ### Returns 106 | > - **Generator** (`None`: `None`): A generator of all edges (arbitrary sort) 107 | 108 | 109 | 110 | ## *Function* `get_node_by_id(self, node_name: Hashable)` 111 | 112 | 113 | Return the data associated with a node. 114 | 115 | ### Arguments 116 | > - **node_name** (`Hashable`: `None`): The node ID to look up 117 | 118 | ### Returns 119 | > - **dict** (`None`: `None`): The metadata associated with this node 120 | 121 | 122 | 123 | ## *Function* `get_edge_by_id(self, u: Hashable, v: Hashable)` 124 | 125 | 126 | Get an edge by its source and target IDs. 127 | 128 | ### Arguments 129 | > - **u** (`Hashable`: `None`): The source node ID 130 | > - **v** (`Hashable`: `None`): The target node ID 131 | 132 | ### Returns 133 | > - **dict** (`None`: `None`): Metadata associated with this edge 134 | 135 | 136 | 137 | ## *Function* `get_node_count(self) -> Iterable` 138 | 139 | 140 | Get an integer count of the number of nodes in this graph. 141 | 142 | ### Arguments 143 | None 144 | 145 | ### Returns 146 | > - **int** (`None`: `None`): The count of nodes 147 | 148 | -------------------------------------------------------------------------------- /docs/reference/backends/gremlin.py.md: -------------------------------------------------------------------------------- 1 | ## *Class* `GremlinBackend(Backend)` 2 | 3 | 4 | A backend instance for Gremlin-compatible graph databases. 5 | 6 | 7 | 8 | ## *Function* `__init__(self, graph: GraphTraversalSource, directed: bool = True)` 9 | 10 | 11 | Create a new Backend instance wrapping a Gremlin endpoint. 12 | 13 | ### Arguments 14 | > - **directed** (`bool`: `False`): Whether to make the backend graph directed 15 | 16 | ### Returns 17 | None 18 | 19 | 20 | 21 | ## *Function* `is_directed(self) -> bool` 22 | 23 | 24 | Return True if the backend graph is directed. 25 | 26 | The Gremlin-backed datastore is always directed. 27 | 28 | ### Arguments 29 | None 30 | 31 | ### Returns 32 | > - **bool** (`None`: `None`): True if the backend graph is directed. 33 | 34 | 35 | 36 | ## *Function* `add_node(self, node_name: Hashable, metadata: dict)` 37 | 38 | 39 | Add a new node to the graph. 40 | 41 | ### Arguments 42 | > - **node_name** (`Hashable`: `None`): The ID of the node 43 | > - **metadata** (`dict`: `None`): An optional dictionary of metadata 44 | 45 | ### Returns 46 | > - **Hashable** (`None`: `None`): The ID of this node, as inserted 47 | 48 | 49 | 50 | ## *Function* `get_node_by_id(self, node_name: Hashable)` 51 | 52 | 53 | Return the data associated with a node. 54 | 55 | ### Arguments 56 | > - **node_name** (`Hashable`: `None`): The node ID to look up 57 | 58 | ### Returns 59 | > - **dict** (`None`: `None`): The metadata associated with this node 60 | 61 | 62 | 63 | ## *Function* `has_node(self, u: Hashable) -> bool` 64 | 65 | 66 | Return the data associated with a node. 67 | 68 | ### Arguments 69 | > - **node_name** (`Hashable`: `None`): The node ID to look up 70 | 71 | ### Returns 72 | > - **dict** (`None`: `None`): The metadata associated with this node 73 | 74 | 75 | 76 | ## *Function* `remove_node(self, node_name: Hashable)` 77 | 78 | 79 | Remove a node. 80 | 81 | ### Arguments 82 | > - **node_name** (`Hashable`: `None`): The node ID to look up 83 | 84 | ### Returns 85 | > - **dict** (`None`: `None`): The metadata associated with this node 86 | 87 | 88 | 89 | ## *Function* `all_nodes_as_iterable(self, include_metadata: bool = False) -> Generator` 90 | 91 | 92 | Get a generator of all of the nodes in this graph. 93 | 94 | ### Arguments 95 | > - **include_metadata** (`bool`: `False`): Whether to include node metadata in 96 | the response 97 | 98 | ### Returns 99 | > - **Generator** (`None`: `None`): A generator of all nodes (arbitrary sort) 100 | 101 | 102 | 103 | ## *Function* `add_edge(self, u: Hashable, v: Hashable, metadata: dict)` 104 | 105 | 106 | Add a new edge to the graph between two nodes. 107 | 108 | If the graph is directed, this edge will start (source) at the `u` node and end (target) at the `v` node. 109 | 110 | ### Arguments 111 | > - **u** (`Hashable`: `None`): The source node ID 112 | > - **v** (`Hashable`: `None`): The target node ID 113 | > - **metadata** (`dict`: `None`): Optional metadata to associate with the edge 114 | 115 | ### Returns 116 | > - **Hashable** (`None`: `None`): The edge ID, as inserted. 117 | 118 | 119 | 120 | ## *Function* `all_edges_as_iterable(self, include_metadata: bool = False) -> Generator` 121 | 122 | 123 | Get a list of all edges in this graph, arbitrary sort. 124 | 125 | ### Arguments 126 | > - **include_metadata** (`bool`: `False`): Whether to include edge metadata 127 | 128 | ### Returns 129 | > - **Generator** (`None`: `None`): A generator of all edges (arbitrary sort) 130 | 131 | 132 | 133 | ## *Function* `get_edge_by_id(self, u: Hashable, v: Hashable)` 134 | 135 | 136 | Get an edge by its source and target IDs. 137 | 138 | ### Arguments 139 | > - **u** (`Hashable`: `None`): The source node ID 140 | > - **v** (`Hashable`: `None`): The target node ID 141 | 142 | ### Returns 143 | > - **dict** (`None`: `None`): Metadata associated with this edge 144 | 145 | 146 | 147 | ## *Function* `get_node_count(self) -> Iterable` 148 | 149 | 150 | Get an integer count of the number of nodes in this graph. 151 | 152 | ### Arguments 153 | None 154 | 155 | ### Returns 156 | > - **int** (`None`: `None`): The count of nodes 157 | 158 | -------------------------------------------------------------------------------- /docs/reference/backends/igraph.py.md: -------------------------------------------------------------------------------- 1 | ## *Class* `IGraphBackend(Backend)` 2 | 3 | 4 | This is currently UNOPTIMIZED CODE. 5 | 6 | Recommendations for future work include improved indexing and caching of node names and metadata. 7 | 8 | 9 | 10 | ## *Function* `__init__(self, directed: bool = False)` 11 | 12 | 13 | Create a new IGraphBackend instance, using an igraph.Graph object to store and manage network structure. 14 | 15 | ### Arguments 16 | > - **directed** (`bool`: `False`): Whether to make the backend graph directed 17 | 18 | ### Returns 19 | None 20 | 21 | 22 | 23 | ## *Function* `is_directed(self) -> bool` 24 | 25 | 26 | Return True if the backend graph is directed. 27 | 28 | ### Arguments 29 | None 30 | 31 | ### Returns 32 | > - **bool** (`None`: `None`): True if the backend graph is directed. 33 | 34 | 35 | 36 | ## *Function* `add_node(self, node_name: Hashable, metadata: dict)` 37 | 38 | 39 | Add a new node to the graph. 40 | 41 | ### Arguments 42 | > - **node_name** (`Hashable`: `None`): The ID of the node 43 | > - **metadata** (`dict`: `None`): An optional dictionary of metadata 44 | 45 | ### Returns 46 | > - **Hashable** (`None`: `None`): The ID of this node, as inserted 47 | 48 | 49 | 50 | ## *Function* `get_node_by_id(self, node_name: Hashable)` 51 | 52 | 53 | Return the data associated with a node. 54 | 55 | ### Arguments 56 | > - **node_name** (`Hashable`: `None`): The node ID to look up 57 | 58 | ### Returns 59 | > - **dict** (`None`: `None`): The metadata associated with this node 60 | 61 | 62 | 63 | ## *Function* `all_nodes_as_iterable(self, include_metadata: bool = False) -> Generator` 64 | 65 | 66 | Get a generator of all of the nodes in this graph. 67 | 68 | ### Arguments 69 | > - **include_metadata** (`bool`: `False`): Whether to include node metadata in 70 | the response 71 | 72 | ### Returns 73 | > - **Generator** (`None`: `None`): A generator of all nodes (arbitrary sort) 74 | 75 | 76 | 77 | ## *Function* `has_node(self, u: Hashable) -> bool` 78 | 79 | 80 | Return true if the node exists in the graph. 81 | 82 | ### Arguments 83 | > - **u** (`Hashable`: `None`): The ID of the node to check 84 | 85 | ### Returns 86 | > - **bool** (`None`: `None`): True if the node exists 87 | 88 | 89 | ## *Function* `add_edge(self, u: Hashable, v: Hashable, metadata: dict)` 90 | 91 | 92 | Add a new edge to the graph between two nodes. 93 | 94 | If the graph is directed, this edge will start (source) at the `u` node and end (target) at the `v` node. 95 | 96 | ### Arguments 97 | > - **u** (`Hashable`: `None`): The source node ID 98 | > - **v** (`Hashable`: `None`): The target node ID 99 | > - **metadata** (`dict`: `None`): Optional metadata to associate with the edge 100 | 101 | ### Returns 102 | > - **Hashable** (`None`: `None`): The edge ID, as inserted. 103 | 104 | 105 | 106 | ## *Function* `all_edges_as_iterable(self, include_metadata: bool = False) -> Generator` 107 | 108 | 109 | Get a list of all edges in this graph, arbitrary sort. 110 | 111 | ### Arguments 112 | > - **include_metadata** (`bool`: `False`): Whether to include edge metadata 113 | 114 | ### Returns 115 | > - **Generator** (`None`: `None`): A generator of all edges (arbitrary sort) 116 | 117 | 118 | 119 | ## *Function* `get_edge_by_id(self, u: Hashable, v: Hashable)` 120 | 121 | 122 | Get an edge by its source and target IDs. 123 | 124 | ### Arguments 125 | > - **u** (`Hashable`: `None`): The source node ID 126 | > - **v** (`Hashable`: `None`): The target node ID 127 | 128 | ### Returns 129 | > - **dict** (`None`: `None`): Metadata associated with this edge 130 | 131 | 132 | 133 | ## *Function* `get_node_count(self) -> Iterable` 134 | 135 | 136 | Get an integer count of the number of nodes in this graph. 137 | 138 | ### Arguments 139 | None 140 | 141 | ### Returns 142 | > - **int** (`None`: `None`): The count of nodes 143 | 144 | -------------------------------------------------------------------------------- /docs/reference/backends/metadatastore.py.md: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aplbrain/grand/ce815357e73c05c947b1194220f531430e6fc2cc/docs/reference/backends/metadatastore.py.md -------------------------------------------------------------------------------- /docs/reference/backends/networkit.py.md: -------------------------------------------------------------------------------- 1 | ## *Class* `NetworkitBackend(Backend)` 2 | 3 | 4 | Networkit doesn't support metadata or named nodes, so all node names and metadata must currently be stored in a parallel data structure. 5 | 6 | To solve this problem, a NodeNameManager and MetadataStore, from `grand.backends.metadatastore.NodeNameManager` and `grand.backends.metadatastore.MetadataStore` respectively, are included at the top level of this class. In order to preserve this metadata structure statefully, you must serialize both the graph as well as the data stores. 7 | 8 | This is currently UNOPTIMIZED CODE. 9 | 10 | Recommendations for future work include improved indexing and caching of node names and metadata. 11 | 12 | > - **documentation** (`None`: `None`): https://networkit.github.io/dev-docs/python_api/graph.html 13 | 14 | 15 | 16 | ## *Function* `__init__(self, directed: bool = False, metadata_store: MetadataStore = None)` 17 | 18 | 19 | Create a new NetworkitBackend instance, using a Networkit.graph.Graph object to store and manage network structure. 20 | 21 | ### Arguments 22 | > - **directed** (`bool`: `False`): Whether to make the backend graph directed 23 | > - **metadata_store** (`MetadataStore`: `None`): Optionally, a MetadataStore to use 24 | to handle node and edge attributes. If not provided, defaults to a DictMetadataStore. 25 | 26 | ### Returns 27 | None 28 | 29 | 30 | 31 | ## *Function* `is_directed(self) -> bool` 32 | 33 | 34 | Return True if the backend graph is directed. 35 | 36 | ### Arguments 37 | None 38 | 39 | ### Returns 40 | > - **bool** (`None`: `None`): True if the backend graph is directed. 41 | 42 | 43 | 44 | ## *Function* `add_node(self, node_name: Hashable, metadata: dict)` 45 | 46 | 47 | Add a new node to the graph. 48 | 49 | ### Arguments 50 | > - **node_name** (`Hashable`: `None`): The ID of the node 51 | > - **metadata** (`dict`: `None`): An optional dictionary of metadata 52 | 53 | ### Returns 54 | > - **Hashable** (`None`: `None`): The ID of this node, as inserted 55 | 56 | 57 | 58 | ## *Function* `get_node_by_id(self, node_name: Hashable)` 59 | 60 | 61 | Return the data associated with a node. 62 | 63 | ### Arguments 64 | > - **node_name** (`Hashable`: `None`): The node ID to look up 65 | 66 | ### Returns 67 | > - **dict** (`None`: `None`): The metadata associated with this node 68 | 69 | 70 | 71 | ## *Function* `all_nodes_as_iterable(self, include_metadata: bool = False) -> Generator` 72 | 73 | 74 | Get a generator of all of the nodes in this graph. 75 | 76 | ### Arguments 77 | > - **include_metadata** (`bool`: `False`): Whether to include node metadata in 78 | the response 79 | 80 | ### Returns 81 | > - **Generator** (`None`: `None`): A generator of all nodes (arbitrary sort) 82 | 83 | 84 | 85 | ## *Function* `has_node(self, u: Hashable) -> bool` 86 | 87 | 88 | Return true if the node exists in the graph. 89 | 90 | ### Arguments 91 | > - **u** (`Hashable`: `None`): The ID of the node to check 92 | 93 | ### Returns 94 | > - **bool** (`None`: `None`): True if the node exists 95 | 96 | 97 | ## *Function* `add_edge(self, u: Hashable, v: Hashable, metadata: dict)` 98 | 99 | 100 | Add a new edge to the graph between two nodes. 101 | 102 | If the graph is directed, this edge will start (source) at the `u` node and end (target) at the `v` node. 103 | 104 | ### Arguments 105 | > - **u** (`Hashable`: `None`): The source node ID 106 | > - **v** (`Hashable`: `None`): The target node ID 107 | > - **metadata** (`dict`: `None`): Optional metadata to associate with the edge 108 | 109 | ### Returns 110 | > - **Hashable** (`None`: `None`): The edge ID, as inserted. 111 | 112 | 113 | 114 | ## *Function* `all_edges_as_iterable(self, include_metadata: bool = False) -> Generator` 115 | 116 | 117 | Get a list of all edges in this graph, arbitrary sort. 118 | 119 | ### Arguments 120 | > - **include_metadata** (`bool`: `False`): Whether to include edge metadata 121 | 122 | ### Returns 123 | > - **Generator** (`None`: `None`): A generator of all edges (arbitrary sort) 124 | 125 | 126 | 127 | ## *Function* `get_edge_by_id(self, u: Hashable, v: Hashable)` 128 | 129 | 130 | Get an edge by its source and target IDs. 131 | 132 | ### Arguments 133 | > - **u** (`Hashable`: `None`): The source node ID 134 | > - **v** (`Hashable`: `None`): The target node ID 135 | 136 | ### Returns 137 | > - **dict** (`None`: `None`): Metadata associated with this edge 138 | 139 | 140 | 141 | ## *Function* `get_node_count(self) -> Iterable` 142 | 143 | 144 | Get an integer count of the number of nodes in this graph. 145 | 146 | ### Arguments 147 | None 148 | 149 | ### Returns 150 | > - **int** (`None`: `None`): The count of nodes 151 | 152 | -------------------------------------------------------------------------------- /docs/reference/backends/networkx.py.md: -------------------------------------------------------------------------------- 1 | ## *Function* `__init__(self, directed: bool = False)` 2 | 3 | 4 | Create a new Backend instance. 5 | 6 | ### Arguments 7 | > - **directed** (`bool`: `False`): Whether to make the backend graph directed 8 | 9 | ### Returns 10 | None 11 | 12 | 13 | 14 | ## *Function* `is_directed(self) -> bool` 15 | 16 | 17 | Return True if the backend graph is directed. 18 | 19 | ### Arguments 20 | None 21 | 22 | ### Returns 23 | > - **bool** (`None`: `None`): True if the backend graph is directed. 24 | 25 | 26 | 27 | ## *Function* `add_node(self, node_name: Hashable, metadata: dict)` 28 | 29 | 30 | Add a new node to the graph. 31 | 32 | ### Arguments 33 | > - **node_name** (`Hashable`: `None`): The ID of the node 34 | > - **metadata** (`dict`: `None`): An optional dictionary of metadata 35 | 36 | ### Returns 37 | > - **Hashable** (`None`: `None`): The ID of this node, as inserted 38 | 39 | 40 | 41 | ## *Function* `get_node_by_id(self, node_name: Hashable)` 42 | 43 | 44 | Return the data associated with a node. 45 | 46 | ### Arguments 47 | > - **node_name** (`Hashable`: `None`): The node ID to look up 48 | 49 | ### Returns 50 | > - **dict** (`None`: `None`): The metadata associated with this node 51 | 52 | 53 | 54 | ## *Function* `all_nodes_as_iterable(self, include_metadata: bool = False) -> Generator` 55 | 56 | 57 | Get a generator of all of the nodes in this graph. 58 | 59 | ### Arguments 60 | > - **include_metadata** (`bool`: `False`): Whether to include node metadata in 61 | the response 62 | 63 | ### Returns 64 | > - **Generator** (`None`: `None`): A generator of all nodes (arbitrary sort) 65 | 66 | 67 | 68 | ## *Function* `add_edge(self, u: Hashable, v: Hashable, metadata: dict)` 69 | 70 | 71 | Add a new edge to the graph between two nodes. 72 | 73 | If the graph is directed, this edge will start (source) at the `u` node and end (target) at the `v` node. 74 | 75 | ### Arguments 76 | > - **u** (`Hashable`: `None`): The source node ID 77 | > - **v** (`Hashable`: `None`): The target node ID 78 | > - **metadata** (`dict`: `None`): Optional metadata to associate with the edge 79 | 80 | ### Returns 81 | > - **Hashable** (`None`: `None`): The edge ID, as inserted. 82 | 83 | 84 | 85 | ## *Function* `all_edges_as_iterable(self, include_metadata: bool = False) -> Generator` 86 | 87 | 88 | Get a list of all edges in this graph, arbitrary sort. 89 | 90 | ### Arguments 91 | > - **include_metadata** (`bool`: `False`): Whether to include edge metadata 92 | 93 | ### Returns 94 | > - **Generator** (`None`: `None`): A generator of all edges (arbitrary sort) 95 | 96 | 97 | 98 | ## *Function* `get_edge_by_id(self, u: Hashable, v: Hashable)` 99 | 100 | 101 | Get an edge by its source and target IDs. 102 | 103 | ### Arguments 104 | > - **u** (`Hashable`: `None`): The source node ID 105 | > - **v** (`Hashable`: `None`): The target node ID 106 | 107 | ### Returns 108 | > - **dict** (`None`: `None`): Metadata associated with this edge 109 | 110 | 111 | 112 | ## *Function* `get_node_count(self) -> Iterable` 113 | 114 | 115 | Get an integer count of the number of nodes in this graph. 116 | 117 | ### Arguments 118 | None 119 | 120 | ### Returns 121 | > - **int** (`None`: `None`): The count of nodes 122 | 123 | -------------------------------------------------------------------------------- /docs/reference/backends/sqlbackend.py.md: -------------------------------------------------------------------------------- 1 | ## *Class* `SQLBackend(Backend)` 2 | 3 | 4 | A graph datastore that uses a SQL-like store for persistance and queries. 5 | 6 | 7 | 8 | ## *Function* `is_directed(self) -> bool` 9 | 10 | 11 | Return True if the backend graph is directed. 12 | 13 | ### Arguments 14 | None 15 | 16 | ### Returns 17 | > - **bool** (`None`: `None`): True if the backend graph is directed. 18 | 19 | 20 | 21 | ## *Function* `teardown(self, yes_i_am_sure: bool = False)` 22 | 23 | 24 | Tear down this graph, deleting all evidence it once was here. 25 | 26 | 27 | 28 | ## *Function* `add_node(self, node_name: Hashable, metadata: dict) -> Hashable` 29 | 30 | 31 | Add a new node to the graph. 32 | 33 | Insert a new document into the nodes table. 34 | 35 | ### Arguments 36 | > - **node_name** (`Hashable`: `None`): The ID of the node 37 | > - **metadata** (`dict`: `None`): An optional dictionary of metadata 38 | 39 | ### Returns 40 | > - **Hashable** (`None`: `None`): The ID of this node, as inserted 41 | 42 | 43 | 44 | ## *Function* `all_nodes_as_iterable(self, include_metadata: bool = False) -> Generator` 45 | 46 | 47 | Get a generator of all of the nodes in this graph. 48 | 49 | ### Arguments 50 | > - **include_metadata** (`bool`: `False`): Whether to include node metadata in 51 | the response 52 | 53 | ### Returns 54 | > - **Generator** (`None`: `None`): A generator of all nodes (arbitrary sort) 55 | 56 | 57 | 58 | ## *Function* `has_node(self, u: Hashable) -> bool` 59 | 60 | 61 | Return true if the node exists in the graph. 62 | 63 | ### Arguments 64 | > - **u** (`Hashable`: `None`): The ID of the node to check 65 | 66 | ### Returns 67 | > - **bool** (`None`: `None`): True if the node exists 68 | 69 | 70 | ## *Function* `add_edge(self, u: Hashable, v: Hashable, metadata: dict)` 71 | 72 | 73 | Add a new edge to the graph between two nodes. 74 | 75 | If the graph is directed, this edge will start (source) at the `u` node and end (target) at the `v` node. 76 | 77 | ### Arguments 78 | > - **u** (`Hashable`: `None`): The source node ID 79 | > - **v** (`Hashable`: `None`): The target node ID 80 | > - **metadata** (`dict`: `None`): Optional metadata to associate with the edge 81 | 82 | ### Returns 83 | > - **Hashable** (`None`: `None`): The edge ID, as inserted. 84 | 85 | 86 | 87 | ## *Function* `all_edges_as_iterable(self, include_metadata: bool = False) -> Generator` 88 | 89 | 90 | Get a list of all edges in this graph, arbitrary sort. 91 | 92 | ### Arguments 93 | > - **include_metadata** (`bool`: `False`): Whether to include edge metadata 94 | 95 | ### Returns 96 | > - **Generator** (`None`: `None`): A generator of all edges (arbitrary sort) 97 | 98 | 99 | 100 | ## *Function* `get_node_by_id(self, node_name: Hashable)` 101 | 102 | 103 | Return the data associated with a node. 104 | 105 | ### Arguments 106 | > - **node_name** (`Hashable`: `None`): The node ID to look up 107 | 108 | ### Returns 109 | > - **dict** (`None`: `None`): The metadata associated with this node 110 | 111 | 112 | 113 | ## *Function* `get_edge_by_id(self, u: Hashable, v: Hashable)` 114 | 115 | 116 | Get an edge by its source and target IDs. 117 | 118 | ### Arguments 119 | > - **u** (`Hashable`: `None`): The source node ID 120 | > - **v** (`Hashable`: `None`): The target node ID 121 | 122 | ### Returns 123 | > - **dict** (`None`: `None`): Metadata associated with this edge 124 | 125 | 126 | 127 | ## *Function* `get_node_count(self) -> Iterable` 128 | 129 | 130 | Get an integer count of the number of nodes in this graph. 131 | 132 | ### Arguments 133 | None 134 | 135 | ### Returns 136 | > - **int** (`None`: `None`): The count of nodes 137 | 138 | -------------------------------------------------------------------------------- /docs/reference/dialects/dialects.md: -------------------------------------------------------------------------------- 1 | ## *Class* `NetworkXDialect(nx.Graph)` 2 | 3 | 4 | A NetworkXDialect provides a networkx-like interface for graph manipulation 5 | 6 | 7 | 8 | ## *Function* `__init__(self, parent: "Graph")` 9 | 10 | 11 | Create a new dialect to query a backend with NetworkX syntax. 12 | 13 | ### Arguments 14 | > - **parent** (`Graph`: `None`): The parent Graph object 15 | 16 | ### Returns 17 | None 18 | 19 | 20 | 21 | ## *Function* `adj(self)` 22 | 23 | 24 | https://github.com/networkx/networkx/blob/master/networkx/classes/digraph.py#L323 25 | 26 | 27 | ## *Function* `_adj(self)` 28 | 29 | 30 | https://github.com/networkx/networkx/blob/master/networkx/classes/digraph.py#L323 31 | 32 | 33 | ## *Function* `pred(self)` 34 | 35 | 36 | https://github.com/networkx/networkx/blob/master/networkx/classes/digraph.py#L323 37 | 38 | 39 | ## *Function* `_pred(self)` 40 | 41 | 42 | https://github.com/networkx/networkx/blob/master/networkx/classes/digraph.py#L323 43 | 44 | 45 | ## *Class* `IGraphDialect(nx.Graph)` 46 | 47 | 48 | An IGraphDialect provides a python-igraph-like interface 49 | 50 | 51 | 52 | ## *Function* `__init__(self, parent: "Graph")` 53 | 54 | 55 | Create a new dialect to query a backend with Python-IGraph syntax. 56 | 57 | ### Arguments 58 | > - **parent** (`Graph`: `None`): The parent Graph object 59 | 60 | ### Returns 61 | None 62 | 63 | 64 | 65 | ## *Class* `NetworkitDialect` 66 | 67 | 68 | A Networkit-like API for interacting with a Grand graph. 69 | 70 | > - **here** (`None`: `None`): https://networkit.github.io/dev-docs/python_api/graph.html 71 | 72 | -------------------------------------------------------------------------------- /docs/reference/grand/grand.md: -------------------------------------------------------------------------------- 1 | ## *Class* `Graph` 2 | 3 | 4 | A grand.Graph enables you to manipulate a graph using multiple dialects. 5 | 6 | -------------------------------------------------------------------------------- /docs/reference/setup.py.md: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aplbrain/grand/ce815357e73c05c947b1194220f531430e6fc2cc/docs/reference/setup.py.md -------------------------------------------------------------------------------- /grand/__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | Grand graphs package. 3 | 4 | """ 5 | 6 | from typing import Optional 7 | from .backends import Backend, NetworkXBackend 8 | from .dialects import NetworkXDialect, IGraphDialect, NetworkitDialect 9 | 10 | 11 | _DEFAULT_BACKEND = NetworkXBackend 12 | 13 | __version__ = "0.7.0" 14 | 15 | 16 | class Graph: 17 | """ 18 | A grand.Graph enables you to manipulate a graph using multiple dialects. 19 | 20 | """ 21 | 22 | nx: NetworkXDialect 23 | networkit: NetworkitDialect 24 | igraph: IGraphDialect 25 | 26 | def __init__(self, backend: Optional[Backend] = None, **backend_kwargs: dict): 27 | """ 28 | Create a new grand.Graph. 29 | 30 | The only positional argument is the backend to use. All other arguments 31 | are passed to the backend's constructor, if a type is provided. 32 | Otherwise, kwargs are ignored. 33 | 34 | Arguments: 35 | backend (Backend): The backend to use. If none is provided, will 36 | default to _DEFAULT_BACKEND. 37 | 38 | """ 39 | self.backend = backend or _DEFAULT_BACKEND 40 | 41 | # If you passed a class instead of an instance, instantiate it with 42 | # kwargs from the constructor: 43 | if isinstance(self.backend, type): 44 | self.backend = self.backend(**backend_kwargs) 45 | 46 | # Attach dialects: 47 | self.attach_dialect("nx", NetworkXDialect) 48 | self.attach_dialect("igraph", IGraphDialect) 49 | self.attach_dialect("networkit", NetworkitDialect) 50 | 51 | def attach_dialect(self, name: str, dialect: type): 52 | """ 53 | Attach a dialect to the graph. 54 | 55 | Arguments: 56 | name (str): The name of the dialect. 57 | dialect (type): The dialect class to attach. 58 | 59 | """ 60 | setattr(self, name, dialect(self)) 61 | 62 | 63 | class DiGraph(Graph): 64 | """ 65 | A grand.DiGraph enables you to manipulate a directed graph. This is a 66 | convenience class that inherits from grand.Graph. 67 | 68 | """ 69 | 70 | def __init__(self, backend: Optional[Backend] = None, **backend_kwargs: dict): 71 | """ 72 | Create a new grand.DiGraph. 73 | 74 | The only positional argument is the backend to use. All other arguments 75 | are passed to the backend's constructor, if a type is provided. 76 | Otherwise, kwargs are ignored. 77 | 78 | Arguments: 79 | backend (Backend): The backend to use. If none is provided, will 80 | default to _DEFAULT_BACKEND. 81 | 82 | """ 83 | super().__init__(backend, **{**backend_kwargs, "directed": True}) 84 | -------------------------------------------------------------------------------- /grand/backends/__init__.py: -------------------------------------------------------------------------------- 1 | from .backend import Backend, CachedBackend, InMemoryCachedBackend 2 | 3 | try: 4 | from ._dynamodb import DynamoDBBackend 5 | except ImportError: 6 | pass 7 | from ._networkx import NetworkXBackend 8 | from ._dataframe import DataFrameBackend 9 | 10 | try: 11 | from ._sqlbackend import SQLBackend 12 | except ImportError: 13 | pass 14 | 15 | try: 16 | from ._networkit import NetworkitBackend 17 | except ImportError: 18 | pass 19 | 20 | __all__ = [ 21 | "Backend", 22 | "CachedBackend", 23 | "InMemoryCachedBackend", 24 | "NetworkXBackend", 25 | "DataFrameBackend", 26 | "DynamoDBBackend", 27 | "SQLBackend", 28 | "NetworkitBackend", 29 | ] 30 | -------------------------------------------------------------------------------- /grand/backends/_dataframe.py: -------------------------------------------------------------------------------- 1 | from typing import Hashable, Generator 2 | import time 3 | 4 | import pandas as pd 5 | 6 | from .backend import Backend 7 | 8 | 9 | class DataFrameBackend(Backend): 10 | def __init__( 11 | self, 12 | directed: bool = False, 13 | edge_df: pd.DataFrame = None, 14 | node_df: pd.DataFrame = None, 15 | edge_df_source_column: str = "Source", 16 | edge_df_target_column: str = "Target", 17 | node_df_id_column: str = "id", 18 | ): 19 | """ 20 | Create a new DataFrame backend. 21 | 22 | You must pass an edgelist. A nodelist is optional. 23 | 24 | Arguments: 25 | edge_df (pd.DataFrame): An edgelist dataframe with one edge per row 26 | directed (bool: False): Whether the graph is directed 27 | node_df (pd.DataFrame): A node metadata lookup 28 | edge_df_source_column (str): The name of the column in `edge_df` to 29 | use as the source of edges 30 | edge_df_target_column (str): The name of the column in `edge_df` to 31 | use as the target of edges 32 | node_df_id_column (str): The name of the column in `node_df` to 33 | use as the node ID 34 | """ 35 | self._directed = directed 36 | self._edge_df = ( 37 | edge_df 38 | if edge_df is not None 39 | else pd.DataFrame(columns=[edge_df_source_column, edge_df_target_column]) 40 | ) 41 | self._node_df = node_df if node_df is not None else None 42 | self._edge_df_source_column = edge_df_source_column 43 | self._edge_df_target_column = edge_df_target_column 44 | self._node_df_id_column = node_df_id_column 45 | 46 | def is_directed(self) -> bool: 47 | """ 48 | Return True if the backend graph is directed. 49 | 50 | Arguments: 51 | None 52 | 53 | Returns: 54 | bool: True if the backend graph is directed. 55 | 56 | """ 57 | return self._directed 58 | 59 | def teardown(self, yes_i_am_sure: bool = False): 60 | """ 61 | Tear down this graph, deleting all evidence it once was here. 62 | 63 | """ 64 | return 65 | 66 | def add_node(self, node_name: Hashable, metadata: dict) -> Hashable: 67 | """ 68 | Add a new node to the graph. 69 | 70 | Insert a new document into the nodes table. 71 | 72 | Arguments: 73 | node_name (Hashable): The ID of the node 74 | metadata (dict: None): An optional dictionary of metadata 75 | 76 | Returns: 77 | Hashable: The ID of this node, as inserted 78 | 79 | """ 80 | 81 | # Add a new row to the nodes table: 82 | if self._node_df is None: 83 | self._node_df = pd.DataFrame( 84 | [ 85 | { 86 | self._node_df_id_column: node_name, 87 | **metadata, 88 | } 89 | ], 90 | columns=[ 91 | self._node_df_id_column, 92 | *metadata.keys(), 93 | ], 94 | ) 95 | self._node_df.set_index(self._node_df_id_column, inplace=True) 96 | else: 97 | if self.has_node(node_name): 98 | existing_metadata = self.get_node_by_id(node_name) 99 | existing_metadata.update(metadata) 100 | for k, v in existing_metadata.items(): 101 | self._node_df.at[node_name, k] = v 102 | else: 103 | # Insert a new row: 104 | self._node_df = pd.concat( 105 | [self._node_df, pd.DataFrame([{node_name: metadata}]).T] 106 | ) 107 | 108 | return node_name 109 | 110 | def all_nodes_as_iterable(self, include_metadata: bool = False): 111 | """ 112 | Get a generator of all of the nodes in this graph. 113 | 114 | Arguments: 115 | include_metadata (bool: False): Whether to include node metadata in 116 | the response 117 | 118 | Returns: 119 | Generator: A generator of all nodes (arbitrary sort) 120 | 121 | """ 122 | if self._node_df is not None: 123 | return [ 124 | ( 125 | ( 126 | node_id, 127 | row.to_dict(), 128 | ) 129 | if include_metadata 130 | else node_id 131 | ) 132 | for node_id, row in self._node_df.iterrows() 133 | ] 134 | 135 | else: 136 | return [ 137 | (node_id, {}) if include_metadata else node_id 138 | for node_id in self._edge_df[self._edge_df_source_column] 139 | ] + [ 140 | (node_id, {}) if include_metadata else node_id 141 | for node_id in self._edge_df[self._edge_df_target_column] 142 | ] 143 | 144 | def has_node(self, u: Hashable) -> bool: 145 | """ 146 | Return true if the node exists in the graph. 147 | 148 | Arguments: 149 | u (Hashable): The ID of the node to check 150 | 151 | Returns: 152 | bool: True if the node exists 153 | """ 154 | if self._node_df is not None: 155 | return u in self._node_df.index 156 | 157 | return u in (self._edge_df[self._edge_df_source_column]) or u in ( 158 | self._edge_df[self._edge_df_target_column] 159 | ) 160 | 161 | def add_edge(self, u: Hashable, v: Hashable, metadata: dict): 162 | """ 163 | Add a new edge to the graph between two nodes. 164 | 165 | If the graph is directed, this edge will start (source) at the `u` node 166 | and end (target) at the `v` node. 167 | 168 | Arguments: 169 | u (Hashable): The source node ID 170 | v (Hashable): The target node ID 171 | metadata (dict): Optional metadata to associate with the edge 172 | 173 | Returns: 174 | Hashable: The edge ID, as inserted. 175 | 176 | """ 177 | 178 | if not self.has_node(u): 179 | self.add_node(u, {}) 180 | if not self.has_node(v): 181 | self.add_node(v, {}) 182 | 183 | if self._has_edge(u, v): 184 | # Update the existing edge: 185 | for k, m in metadata.items(): 186 | if self._directed: 187 | self._edge_df.loc[ 188 | (self._edge_df[self._edge_df_source_column] == u) 189 | & (self._edge_df[self._edge_df_target_column] == v), 190 | k, 191 | ] = m 192 | else: 193 | # Check for the edge in both directions: 194 | self._edge_df.loc[ 195 | (self._edge_df[self._edge_df_source_column] == u) 196 | & (self._edge_df[self._edge_df_target_column] == v), 197 | k, 198 | ] = m 199 | self._edge_df.loc[ 200 | (self._edge_df[self._edge_df_source_column] == v) 201 | & (self._edge_df[self._edge_df_target_column] == u), 202 | k, 203 | ] = m 204 | else: 205 | row = { 206 | self._edge_df_source_column: u, 207 | self._edge_df_target_column: v, 208 | **metadata, 209 | } 210 | self._edge_df.loc[len(self._edge_df)] = None 211 | for k, m in row.items(): 212 | self._edge_df.loc[len(self._edge_df) - 1, k] = m 213 | return (u, v) 214 | 215 | def _has_edge(self, u: Hashable, v: Hashable) -> bool: 216 | """ 217 | Return true if the edge exists in the graph. 218 | 219 | Arguments: 220 | u (Hashable): The source node ID 221 | v (Hashable): The target node ID 222 | 223 | Returns: 224 | bool: True if the edge exists 225 | """ 226 | if self._directed: 227 | return ( 228 | (self._edge_df[self._edge_df_source_column] == u) 229 | & (self._edge_df[self._edge_df_target_column] == v) 230 | ).any() 231 | else: 232 | return ( 233 | (self._edge_df[self._edge_df_source_column] == u) 234 | & (self._edge_df[self._edge_df_target_column] == v) 235 | ).any() or ( 236 | (self._edge_df[self._edge_df_source_column] == v) 237 | & (self._edge_df[self._edge_df_target_column] == u) 238 | ).any() 239 | 240 | def all_edges_as_iterable(self, include_metadata: bool = False) -> Generator: 241 | """ 242 | Get a list of all edges in this graph, arbitrary sort. 243 | 244 | Arguments: 245 | include_metadata (bool: False): Whether to include edge metadata 246 | 247 | Returns: 248 | Generator: A generator of all edges (arbitrary sort) 249 | 250 | """ 251 | for _, row in self._edge_df.iterrows(): 252 | if include_metadata: 253 | yield ( 254 | row[self._edge_df_source_column], 255 | row[self._edge_df_target_column], 256 | dict(row), 257 | ) 258 | else: 259 | yield ( 260 | row[self._edge_df_source_column], 261 | row[self._edge_df_target_column], 262 | ) 263 | 264 | def get_node_by_id(self, node_name: Hashable): 265 | """ 266 | Return the data associated with a node. 267 | 268 | Arguments: 269 | node_name (Hashable): The node ID to look up 270 | 271 | Returns: 272 | dict: The metadata associated with this node 273 | 274 | """ 275 | if self._node_df is not None: 276 | res = (self._node_df.loc[node_name]).to_dict() 277 | return res.get(0, res) 278 | 279 | return {} 280 | 281 | def get_edge_by_id(self, u: Hashable, v: Hashable): 282 | """ 283 | Get an edge by its source and target IDs. 284 | 285 | Arguments: 286 | u (Hashable): The source node ID 287 | v (Hashable): The target node ID 288 | 289 | Returns: 290 | dict: Metadata associated with this edge 291 | 292 | """ 293 | if self._directed: 294 | result = self._edge_df[ 295 | (self._edge_df[self._edge_df_source_column] == u) 296 | & (self._edge_df[self._edge_df_target_column] == v) 297 | ] 298 | if len(result): 299 | return self._edge_as_dict(result.iloc[0]) 300 | 301 | else: 302 | left = self._edge_df[ 303 | (self._edge_df[self._edge_df_source_column] == u) 304 | & (self._edge_df[self._edge_df_target_column] == v) 305 | ] 306 | if len(left): 307 | return self._edge_as_dict(left.iloc[0]) 308 | right = self._edge_df[ 309 | (self._edge_df[self._edge_df_source_column] == v) 310 | & (self._edge_df[self._edge_df_target_column] == u) 311 | ] 312 | if len(right): 313 | return self._edge_as_dict(right.iloc[0]) 314 | 315 | def get_node_neighbors(self, u: Hashable, include_metadata: bool = False): 316 | """ 317 | Get a generator of all downstream nodes from this node. 318 | 319 | Arguments: 320 | u (Hashable): The source node ID 321 | 322 | Returns: 323 | Generator 324 | 325 | """ 326 | 327 | if include_metadata: 328 | if self._directed: 329 | return { 330 | (r[self._edge_df_target_column]): self._edge_as_dict(r) 331 | for _, r in self._edge_df[ 332 | (self._edge_df[self._edge_df_source_column] == u) 333 | ].iterrows() 334 | } 335 | else: 336 | return { 337 | ( 338 | r[self._edge_df_source_column] 339 | if r[self._edge_df_source_column] != u 340 | else r[self._edge_df_target_column] 341 | ): self._edge_as_dict(r) 342 | for _, r in self._edge_df[ 343 | (self._edge_df[self._edge_df_source_column] == u) 344 | | (self._edge_df[self._edge_df_target_column] == u) 345 | ].iterrows() 346 | } 347 | 348 | if self._directed: 349 | return iter( 350 | [ 351 | row[self._edge_df_target_column] 352 | for _, row in self._edge_df[ 353 | (self._edge_df[self._edge_df_source_column] == u) 354 | ].iterrows() 355 | ] 356 | ) 357 | else: 358 | return iter( 359 | [ 360 | ( 361 | row[self._edge_df_source_column] 362 | if row[self._edge_df_source_column] != u 363 | else row[self._edge_df_target_column] 364 | ) 365 | for _, row in self._edge_df[ 366 | (self._edge_df[self._edge_df_source_column] == u) 367 | | (self._edge_df[self._edge_df_target_column] == u) 368 | ].iterrows() 369 | ] 370 | ) 371 | 372 | def _edge_as_dict(self, row): 373 | """ 374 | Convert an edge row to a dictionary. 375 | 376 | Arguments: 377 | row (pandas.Series): The edge row 378 | 379 | Returns: 380 | dict: The edge metadata 381 | 382 | """ 383 | r = row.to_dict() 384 | r.pop(self._edge_df_source_column) 385 | r.pop(self._edge_df_target_column) 386 | return r 387 | 388 | def get_node_predecessors(self, u: Hashable, include_metadata: bool = False): 389 | """ 390 | Get a generator of all upstream nodes from this node. 391 | 392 | Arguments: 393 | u (Hashable): The source node ID 394 | 395 | Returns: 396 | Generator 397 | 398 | """ 399 | 400 | if include_metadata: 401 | if self._directed: 402 | return { 403 | ( 404 | r[self._edge_df_target_column] 405 | if r[self._edge_df_target_column] != u 406 | else r[self._edge_df_source_column] 407 | ): self._edge_as_dict(r) 408 | for _, r in self._edge_df[ 409 | (self._edge_df[self._edge_df_target_column] == u) 410 | ].iterrows() 411 | } 412 | else: 413 | return { 414 | ( 415 | r[self._edge_df_target_column] 416 | if r[self._edge_df_target_column] != u 417 | else r[self._edge_df_source_column] 418 | ): self._edge_as_dict(r) 419 | for _, r in self._edge_df[ 420 | (self._edge_df[self._edge_df_target_column] == u) 421 | | (self._edge_df[self._edge_df_source_column] == u) 422 | ].iterrows() 423 | } 424 | 425 | if self._directed: 426 | return iter( 427 | [ 428 | row[self._edge_df_source_column] 429 | for _, row in self._edge_df[ 430 | (self._edge_df[self._edge_df_target_column] == u) 431 | ].iterrows() 432 | ] 433 | ) 434 | else: 435 | return iter( 436 | [ 437 | ( 438 | row[self._edge_df_source_column] 439 | if row[self._edge_df_target_column] != u 440 | else row[self._edge_df_target_column] 441 | ) 442 | for _, row in self._edge_df[ 443 | (self._edge_df[self._edge_df_target_column] == u) 444 | | (self._edge_df[self._edge_df_source_column] == u) 445 | ].iterrows() 446 | ] 447 | ) 448 | 449 | def get_node_count(self) -> int: 450 | """ 451 | Get an integer count of the number of nodes in this graph. 452 | 453 | Arguments: 454 | None 455 | 456 | Returns: 457 | int: The count of nodes 458 | 459 | """ 460 | if self._node_df is not None: 461 | return len(self._node_df) 462 | # Return number of unique sources intersected with number of unique targets 463 | return len( 464 | set(self._edge_df[self._edge_df_source_column]).intersection( 465 | set(self._edge_df[self._edge_df_target_column]) 466 | ) 467 | ) 468 | 469 | def get_edge_count(self) -> int: 470 | """ 471 | Get an integer count of the number of edges in this graph. 472 | 473 | Arguments: 474 | None 475 | 476 | Returns: 477 | int: The count of edges 478 | 479 | """ 480 | return len(self._edge_df) 481 | 482 | def ingest_from_edgelist_dataframe( 483 | self, edgelist: pd.DataFrame, source_column: str, target_column: str 484 | ) -> dict: 485 | """ 486 | Ingest an edgelist from a Pandas DataFrame. 487 | 488 | """ 489 | # Produce edge list: 490 | 491 | edge_tic = time.time() 492 | self._edge_df = edgelist 493 | self._edge_df_source_column = source_column 494 | self._edge_df_target_column = target_column 495 | 496 | return { 497 | "edge_duration": time.time() - edge_tic, 498 | } 499 | -------------------------------------------------------------------------------- /grand/backends/_gremlin.py: -------------------------------------------------------------------------------- 1 | """ 2 | https://tinkerpop.apache.org/docs/current/reference/ 3 | """ 4 | 5 | from typing import Hashable, Collection 6 | 7 | import pandas as pd 8 | from gremlin_python.structure.graph import Graph 9 | from gremlin_python.process.graph_traversal import __, GraphTraversalSource 10 | from gremlin_python.driver.driver_remote_connection import DriverRemoteConnection 11 | 12 | from .backend import Backend 13 | 14 | ID = "__id" 15 | EDGE_NAME = "__edge" 16 | NODE_NAME = "__node" 17 | 18 | 19 | def _node_to_metadata(n): 20 | return {k if isinstance(k, str) else k.name: v for k, v in n.items()} 21 | 22 | 23 | class GremlinBackend(Backend): 24 | """ 25 | A backend instance for Gremlin-compatible graph databases. 26 | 27 | """ 28 | 29 | def __init__(self, graph: GraphTraversalSource, directed: bool = True): 30 | """ 31 | Create a new Backend instance wrapping a Gremlin endpoint. 32 | 33 | Arguments: 34 | directed (bool: False): Whether to make the backend graph directed 35 | 36 | Returns: 37 | None 38 | 39 | """ 40 | self._g = graph 41 | 42 | def is_directed(self) -> bool: 43 | """ 44 | Return True if the backend graph is directed. 45 | 46 | The Gremlin-backed datastore is always directed. 47 | 48 | Arguments: 49 | None 50 | 51 | Returns: 52 | bool: True if the backend graph is directed. 53 | 54 | """ 55 | return True 56 | 57 | def add_node(self, node_name: Hashable, metadata: dict): 58 | """ 59 | Add a new node to the graph. 60 | 61 | Arguments: 62 | node_name (Hashable): The ID of the node 63 | metadata (dict: None): An optional dictionary of metadata 64 | 65 | Returns: 66 | Hashable: The ID of this node, as inserted 67 | 68 | """ 69 | if self.has_node(node_name): 70 | # Retrieve the existing node; we will update the props. 71 | v = self._g.V().has(ID, node_name) 72 | else: 73 | v = self._g.addV().property(ID, node_name) 74 | for key, val in metadata.items(): 75 | v = v.property(key, val) 76 | return v.toList()[0] 77 | 78 | def get_node_by_id(self, node_name: Hashable): 79 | """ 80 | Return the data associated with a node. 81 | 82 | Arguments: 83 | node_name (Hashable): The node ID to look up 84 | 85 | Returns: 86 | dict: The metadata associated with this node 87 | 88 | """ 89 | try: 90 | return _node_to_metadata( 91 | self._g.V().has(ID, node_name).valueMap(True).toList()[0] 92 | ) 93 | except IndexError as e: 94 | raise KeyError() from e 95 | 96 | def has_node(self, u: Hashable) -> bool: 97 | """ 98 | Return the data associated with a node. 99 | 100 | Arguments: 101 | node_name (Hashable): The node ID to look up 102 | 103 | Returns: 104 | dict: The metadata associated with this node 105 | 106 | """ 107 | try: 108 | self.get_node_by_id(u) 109 | return True 110 | except KeyError: 111 | return False 112 | 113 | def remove_node(self, node_name: Hashable): 114 | """ 115 | Remove a node. 116 | 117 | Arguments: 118 | node_name (Hashable): The node ID to look up 119 | 120 | Returns: 121 | dict: The metadata associated with this node 122 | 123 | """ 124 | return self._g.V().has(ID, node_name).drop().toList() 125 | 126 | def all_nodes_as_iterable(self, include_metadata: bool = False) -> Collection: 127 | """ 128 | Get a generator of all of the nodes in this graph. 129 | 130 | Arguments: 131 | include_metadata (bool: False): Whether to include node metadata in 132 | the response 133 | 134 | Returns: 135 | Generator: A generator of all nodes (arbitrary sort) 136 | 137 | """ 138 | if include_metadata: 139 | return iter( 140 | [ 141 | {n[ID][0]: _node_to_metadata(n)} 142 | for n in self._g.V().valueMap(True).toList() 143 | ] 144 | ) 145 | else: 146 | return iter([n[ID] for n in self._g.V().project(ID).by(ID).toList()]) 147 | 148 | def add_edge(self, u: Hashable, v: Hashable, metadata: dict): 149 | """ 150 | Add a new edge to the graph between two nodes. 151 | 152 | If the graph is directed, this edge will start (source) at the `u` node 153 | and end (target) at the `v` node. 154 | 155 | Arguments: 156 | u (Hashable): The source node ID 157 | v (Hashable): The target node ID 158 | metadata (dict): Optional metadata to associate with the edge 159 | 160 | Returns: 161 | Hashable: The edge ID, as inserted. 162 | 163 | """ 164 | try: 165 | self.get_edge_by_id(u, v) 166 | e = self._g.V().has(ID, u).outE().as_("e").inV().has(ID, v).select("e") 167 | except IndexError: 168 | if not self.has_node(u): 169 | self.add_node(u, {}) 170 | if not self.has_node(v): 171 | self.add_node(v, {}) 172 | e = ( 173 | self._g.V() 174 | .has(ID, u) 175 | .addE(EDGE_NAME) 176 | .as_("e") 177 | .to(__.V().has(ID, v)) 178 | .select("e") 179 | ) 180 | for key, val in metadata.items(): 181 | e = e.property(key, val) 182 | return e.toList() 183 | 184 | def all_edges_as_iterable(self, include_metadata: bool = False) -> Collection: 185 | """ 186 | Get a list of all edges in this graph, arbitrary sort. 187 | 188 | Arguments: 189 | include_metadata (bool: False): Whether to include edge metadata 190 | 191 | Returns: 192 | Generator: A generator of all edges (arbitrary sort) 193 | 194 | """ 195 | if include_metadata: 196 | return iter( 197 | [ 198 | (e["source"], e["target"], _node_to_metadata(e["properties"])) 199 | for e in ( 200 | self._g.V() 201 | .outE() 202 | .project("target", "source", "properties") 203 | .by(__.inV().values(ID)) 204 | .by(__.outV().values(ID)) 205 | .by(__.valueMap(True)) 206 | .toList() 207 | ) 208 | ] 209 | ) 210 | return iter( 211 | [ 212 | (e["source"], e["target"]) 213 | for e in self._g.V() 214 | .outE() 215 | .project("target", "source") 216 | .by(__.inV().values(ID)) 217 | .by(__.outV().values(ID)) 218 | .toList() 219 | ] 220 | ) 221 | 222 | def get_edge_by_id(self, u: Hashable, v: Hashable): 223 | """ 224 | Get an edge by its source and target IDs. 225 | 226 | Arguments: 227 | u (Hashable): The source node ID 228 | v (Hashable): The target node ID 229 | 230 | Returns: 231 | dict: Metadata associated with this edge 232 | 233 | """ 234 | return ( 235 | self._g.V() 236 | .has(ID, u) 237 | .outE() 238 | .as_("e") 239 | .inV() 240 | .has(ID, v) 241 | .select("e") 242 | .properties() 243 | .toList() 244 | )[0] 245 | 246 | def get_node_neighbors( 247 | self, u: Hashable, include_metadata: bool = False 248 | ) -> Collection: 249 | """ 250 | Get a generator of all downstream nodes from this node. 251 | 252 | Arguments: 253 | u (Hashable): The source node ID 254 | 255 | Returns: 256 | Generator 257 | 258 | """ 259 | if include_metadata: 260 | return { 261 | e["target"]: _node_to_metadata(e["properties"]) 262 | for e in ( 263 | self._g.V() 264 | .has(ID, u) 265 | .outE() 266 | .project("target", "source", "properties") 267 | .by(__.inV().values(ID)) 268 | .by(__.outV().values(ID)) 269 | .by(__.valueMap(True)) 270 | .toList() 271 | ) 272 | } 273 | return self._g.V().has(ID, u).out().values(ID).toList() 274 | 275 | def get_node_predecessors( 276 | self, u: Hashable, include_metadata: bool = False 277 | ) -> Collection: 278 | """ 279 | Get a generator of all downstream nodes from this node. 280 | 281 | Arguments: 282 | u (Hashable): The source node ID 283 | 284 | Returns: 285 | Generator 286 | 287 | """ 288 | if include_metadata: 289 | return { 290 | e["source"]: e 291 | for e in ( 292 | self._g.V() 293 | .has(ID, u) 294 | .inE() 295 | .project("target", "source", "properties") 296 | .by(__.inV().values(ID)) 297 | .by(__.outV().values(ID)) 298 | .by(__.valueMap(True)) 299 | .toList() 300 | ) 301 | } 302 | return self._g.V().out().has(ID, u).values(ID).toList() 303 | 304 | def get_node_count(self) -> int: 305 | """ 306 | Get an integer count of the number of nodes in this graph. 307 | 308 | Arguments: 309 | None 310 | 311 | Returns: 312 | int: The count of nodes 313 | 314 | """ 315 | return self._g.V().count().toList()[0] 316 | 317 | def get_edge_count(self) -> int: 318 | """ 319 | Get an integer count of the number of edges in this graph. 320 | 321 | Arguments: 322 | None 323 | 324 | Returns: 325 | int: The count of edges 326 | 327 | """ 328 | return self._g.E().count().toList()[0] 329 | 330 | def teardown(self) -> None: 331 | self._g.V().drop().toList() 332 | -------------------------------------------------------------------------------- /grand/backends/_igraph.py: -------------------------------------------------------------------------------- 1 | from typing import Hashable, Collection 2 | 3 | from igraph import Graph, InternalError 4 | import pandas as pd 5 | 6 | from .backend import Backend 7 | 8 | 9 | def _remove_name_from_attributes(attributes: dict): 10 | attrs = attributes 11 | attrs.pop("name") 12 | return attrs 13 | 14 | 15 | class IGraphBackend(Backend): 16 | """ 17 | This is currently UNOPTIMIZED CODE. 18 | 19 | Recommendations for future work include improved indexing and caching of 20 | node names and metadata. 21 | 22 | """ 23 | 24 | def __init__(self, directed: bool = False): 25 | """ 26 | Create a new IGraphBackend instance, using an igraph.Graph object to 27 | store and manage network structure. 28 | 29 | Arguments: 30 | directed (bool: False): Whether to make the backend graph directed 31 | 32 | Returns: 33 | None 34 | 35 | """ 36 | self._directed = directed 37 | self._ig = Graph(directed=self._directed) 38 | 39 | def ingest_from_edgelist_dataframe( 40 | self, edgelist: pd.DataFrame, source_column: str, target_column: str 41 | ) -> None: 42 | """ 43 | Ingest an edgelist from a Pandas DataFrame. 44 | 45 | """ 46 | raise NotImplementedError() 47 | 48 | def is_directed(self) -> bool: 49 | """ 50 | Return True if the backend graph is directed. 51 | 52 | Arguments: 53 | None 54 | 55 | Returns: 56 | bool: True if the backend graph is directed. 57 | 58 | """ 59 | return self._directed 60 | 61 | def add_node(self, node_name: Hashable, metadata: dict): 62 | """ 63 | Add a new node to the graph. 64 | 65 | Arguments: 66 | node_name (Hashable): The ID of the node 67 | metadata (dict: None): An optional dictionary of metadata 68 | 69 | Returns: 70 | Hashable: The ID of this node, as inserted 71 | 72 | """ 73 | if self.has_node(node_name): 74 | # Update metadata 75 | m = self._ig.vs.find(name=node_name) 76 | m.update_attributes(metadata) 77 | return node_name 78 | self._ig.add_vertex(name=node_name, **metadata) 79 | return node_name 80 | 81 | def get_node_by_id(self, node_name: Hashable): 82 | """ 83 | Return the data associated with a node. 84 | 85 | Arguments: 86 | node_name (Hashable): The node ID to look up 87 | 88 | Returns: 89 | dict: The metadata associated with this node 90 | 91 | """ 92 | return _remove_name_from_attributes( 93 | self._ig.vs.find(name=node_name).attributes() 94 | ) 95 | 96 | def all_nodes_as_iterable(self, include_metadata: bool = False) -> Collection: 97 | """ 98 | Get a generator of all of the nodes in this graph. 99 | 100 | Arguments: 101 | include_metadata (bool: False): Whether to include node metadata in 102 | the response 103 | 104 | Returns: 105 | Generator: A generator of all nodes (arbitrary sort) 106 | 107 | """ 108 | if include_metadata: 109 | for v in self._ig.vs: 110 | yield (v["name"], _remove_name_from_attributes(v.attributes())) 111 | else: 112 | for i in self._ig.vs: 113 | yield i["name"] 114 | 115 | def has_node(self, u: Hashable) -> bool: 116 | """ 117 | Return true if the node exists in the graph. 118 | 119 | Arguments: 120 | u (Hashable): The ID of the node to check 121 | 122 | Returns: 123 | bool: True if the node exists 124 | """ 125 | try: 126 | self._ig.vs.find(name=u) 127 | return True 128 | except: 129 | return False 130 | 131 | def add_edge(self, u: Hashable, v: Hashable, metadata: dict): 132 | """ 133 | Add a new edge to the graph between two nodes. 134 | 135 | If the graph is directed, this edge will start (source) at the `u` node 136 | and end (target) at the `v` node. 137 | 138 | Arguments: 139 | u (Hashable): The source node ID 140 | v (Hashable): The target node ID 141 | metadata (dict): Optional metadata to associate with the edge 142 | 143 | Returns: 144 | Hashable: The edge ID, as inserted. 145 | 146 | """ 147 | if self.has_edge(u, v): 148 | # Update metadata 149 | e = self._ig.get_eid(u, v) 150 | self._ig.es[e].update_attributes(metadata) 151 | return 152 | if not self.has_node(u): 153 | self.add_node(u, {}) 154 | if not self.has_node(v): 155 | self.add_node(v, {}) 156 | return self._ig.add_edge(source=u, target=v, **metadata) 157 | 158 | def all_edges_as_iterable(self, include_metadata: bool = False) -> Collection: 159 | """ 160 | Get a list of all edges in this graph, arbitrary sort. 161 | 162 | Arguments: 163 | include_metadata (bool: False): Whether to include edge metadata 164 | 165 | Returns: 166 | Generator: A generator of all edges (arbitrary sort) 167 | 168 | """ 169 | if include_metadata: 170 | for e in self._ig.es: 171 | yield (e.source_vertex["name"], e.target_vertex["name"], e.attributes()) 172 | else: 173 | for e in self._ig.es: 174 | yield e.source_vertex["name"], e.target_vertex["name"] 175 | 176 | def has_edge(self, u, v): 177 | try: 178 | self._ig.get_eid(u, v) 179 | return True 180 | except (InternalError, ValueError): 181 | # InternalError means no such edge 182 | # ValueError means one vertex doesn't exist 183 | return False 184 | 185 | def get_edge_by_id(self, u: Hashable, v: Hashable): 186 | """ 187 | Get an edge by its source and target IDs. 188 | 189 | Arguments: 190 | u (Hashable): The source node ID 191 | v (Hashable): The target node ID 192 | 193 | Returns: 194 | dict: Metadata associated with this edge 195 | 196 | """ 197 | try: 198 | return self._ig.es[self._ig.get_eid(u, v)].attributes() 199 | except InternalError: 200 | raise IndexError(f"The edge ({u}, {v}) is not in the graph.") 201 | 202 | def get_node_successors( 203 | self, u: Hashable, include_metadata: bool = False 204 | ) -> Collection: 205 | return self.get_node_neighbors(u, include_metadata) 206 | 207 | def get_node_neighbors( 208 | self, u: Hashable, include_metadata: bool = False 209 | ) -> Collection: 210 | """ 211 | Get a generator of all downstream nodes from this node. 212 | 213 | Arguments: 214 | u (Hashable): The source node ID 215 | 216 | Returns: 217 | Generator 218 | 219 | """ 220 | if include_metadata: 221 | return { 222 | self._ig.vs[s]["name"]: self.get_edge_by_id(u, s) 223 | for s in self._ig.successors(u) 224 | } 225 | else: 226 | return iter([self._ig.vs[s]["name"] for s in self._ig.successors(u)]) 227 | 228 | def get_node_predecessors( 229 | self, u: Hashable, include_metadata: bool = False 230 | ) -> Collection: 231 | """ 232 | Get a generator of all upstream nodes from this node. 233 | 234 | Arguments: 235 | u (Hashable): The source node ID 236 | 237 | Returns: 238 | Generator 239 | 240 | """ 241 | if include_metadata: 242 | return { 243 | self._ig.vs[s]["name"]: self.get_edge_by_id(s, u) 244 | for s in self._ig.predecessors(u) 245 | } 246 | else: 247 | return iter([self._ig.vs[s]["name"] for s in self._ig.predecessors(u)]) 248 | 249 | def get_node_count(self) -> int: 250 | """ 251 | Get an integer count of the number of nodes in this graph. 252 | 253 | Arguments: 254 | None 255 | 256 | Returns: 257 | int: The count of nodes 258 | 259 | """ 260 | return self._ig.vcount() 261 | 262 | def get_edge_count(self) -> int: 263 | """ 264 | Get an integer count of the number of edges in this graph. 265 | 266 | Arguments: 267 | None 268 | 269 | Returns: 270 | int: The count of edges 271 | 272 | """ 273 | return self._ig.ecount() 274 | -------------------------------------------------------------------------------- /grand/backends/_networkit.py: -------------------------------------------------------------------------------- 1 | from typing import Hashable, Generator, Iterable 2 | import abc 3 | 4 | import networkit 5 | import pandas as pd 6 | 7 | from .backend import Backend 8 | from .metadatastore import MetadataStore, DictMetadataStore, NodeNameManager 9 | 10 | 11 | class NetworkitBackend(Backend): 12 | """ 13 | Networkit doesn't support metadata or named nodes, so all node names and 14 | metadata must currently be stored in a parallel data structure. 15 | 16 | To solve this problem, a NodeNameManager and MetadataStore, from 17 | `grand.backends.metadatastore.NodeNameManager` and 18 | `grand.backends.metadatastore.MetadataStore` respectively, are included at 19 | the top level of this class. In order to preserve this metadata structure 20 | statefully, you must serialize both the graph as well as the data stores. 21 | 22 | This is currently UNOPTIMIZED CODE. 23 | 24 | Recommendations for future work include improved indexing and caching of 25 | node names and metadata. 26 | 27 | Networkit.graph.Graph documentation: 28 | https://networkit.github.io/dev-docs/python_api/graph.html 29 | 30 | """ 31 | 32 | def __init__(self, directed: bool = False, metadata_store: MetadataStore = None): 33 | """ 34 | Create a new NetworkitBackend instance, using a Networkit.graph.Graph 35 | object to store and manage network structure. 36 | 37 | Arguments: 38 | directed (bool: False): Whether to make the backend graph directed 39 | metadata_store (MetadataStore): Optionally, a MetadataStore to use 40 | to handle node and edge attributes. If not provided, defaults 41 | to a DictMetadataStore. 42 | 43 | Returns: 44 | None 45 | 46 | """ 47 | self._directed = directed 48 | self._meta = metadata_store or DictMetadataStore() 49 | self._nk_graph = networkit.graph.Graph(directed=directed) 50 | self._names = NodeNameManager() 51 | 52 | def ingest_from_edgelist_dataframe( 53 | self, edgelist: pd.DataFrame, source_column: str, target_column: str 54 | ) -> None: 55 | """ 56 | Ingest an edgelist from a Pandas DataFrame. 57 | 58 | """ 59 | raise NotImplementedError() 60 | 61 | def is_directed(self) -> bool: 62 | """ 63 | Return True if the backend graph is directed. 64 | 65 | Arguments: 66 | None 67 | 68 | Returns: 69 | bool: True if the backend graph is directed. 70 | 71 | """ 72 | return self._directed 73 | 74 | def add_node(self, node_name: Hashable, metadata: dict): 75 | """ 76 | Add a new node to the graph. 77 | 78 | Arguments: 79 | node_name (Hashable): The ID of the node 80 | metadata (dict: None): An optional dictionary of metadata 81 | 82 | Returns: 83 | Hashable: The ID of this node, as inserted 84 | 85 | """ 86 | # TODO: Remove metadata from lookup if insertion fails 87 | nk_id = self._nk_graph.addNode() 88 | self._names.add_node(node_name, nk_id) 89 | self._meta.add_node(node_name, metadata) 90 | return nk_id 91 | 92 | def get_node_by_id(self, node_name: Hashable): 93 | """ 94 | Return the data associated with a node. 95 | 96 | Arguments: 97 | node_name (Hashable): The node ID to look up 98 | 99 | Returns: 100 | dict: The metadata associated with this node 101 | 102 | """ 103 | return self._meta.get_node(node_name) 104 | 105 | def all_nodes_as_iterable(self, include_metadata: bool = False) -> Generator: 106 | """ 107 | Get a generator of all of the nodes in this graph. 108 | 109 | Arguments: 110 | include_metadata (bool: False): Whether to include node metadata in 111 | the response 112 | 113 | Returns: 114 | Generator: A generator of all nodes (arbitrary sort) 115 | 116 | """ 117 | if include_metadata: 118 | return [ 119 | (self._names.get_name(i), self._meta.get_node(self._names.get_name(i))) 120 | for i in self._nk_graph.iterNodes() 121 | ] 122 | return [self._names.get_name(i) for i in self._nk_graph.iterNodes()] 123 | 124 | def has_node(self, u: Hashable) -> bool: 125 | """ 126 | Return true if the node exists in the graph. 127 | 128 | Arguments: 129 | u (Hashable): The ID of the node to check 130 | 131 | Returns: 132 | bool: True if the node exists 133 | """ 134 | return u in self._names 135 | 136 | def add_edge(self, u: Hashable, v: Hashable, metadata: dict): 137 | """ 138 | Add a new edge to the graph between two nodes. 139 | 140 | If the graph is directed, this edge will start (source) at the `u` node 141 | and end (target) at the `v` node. 142 | 143 | Arguments: 144 | u (Hashable): The source node ID 145 | v (Hashable): The target node ID 146 | metadata (dict): Optional metadata to associate with the edge 147 | 148 | Returns: 149 | Hashable: The edge ID, as inserted. 150 | 151 | """ 152 | # If u doesn't exist: 153 | if self.has_node(u): 154 | x = self._names.get_id(u) 155 | else: 156 | x = self.add_node(u, None) 157 | 158 | if self.has_node(v): 159 | y = self._names.get_id(v) 160 | else: 161 | y = self.add_node(v, None) 162 | 163 | # Insert metadata for this edge, replacing the previous metadata: 164 | self._meta.add_edge(u, v, metadata) 165 | 166 | # TODO: Support multigraphs, and allow duplicate edges. 167 | if self.has_edge(u, v): 168 | return 169 | return self._nk_graph.addEdge(x, y) 170 | 171 | def all_edges_as_iterable(self, include_metadata: bool = False) -> Generator: 172 | """ 173 | Get a list of all edges in this graph, arbitrary sort. 174 | 175 | Arguments: 176 | include_metadata (bool: False): Whether to include edge metadata 177 | 178 | Returns: 179 | Generator: A generator of all edges (arbitrary sort) 180 | 181 | """ 182 | if include_metadata: 183 | return [ 184 | ( 185 | self._names.get_name(u), 186 | self._names.get_name(v), 187 | self._meta.get_edge( 188 | self._names.get_name(u), self._names.get_name(v) 189 | ), 190 | ) 191 | for u, v in self._nk_graph.iterEdges() 192 | ] 193 | return [ 194 | (self._names.get_name(u), self._names.get_name(v)) 195 | for u, v in self._nk_graph.iterEdges() 196 | ] 197 | 198 | def has_edge(self, u, v): 199 | return self._nk_graph.hasEdge(self._names.get_id(u), self._names.get_id(v)) 200 | 201 | def get_edge_by_id(self, u: Hashable, v: Hashable): 202 | """ 203 | Get an edge by its source and target IDs. 204 | 205 | Arguments: 206 | u (Hashable): The source node ID 207 | v (Hashable): The target node ID 208 | 209 | Returns: 210 | dict: Metadata associated with this edge 211 | 212 | """ 213 | if self.has_edge(u, v): 214 | return self._meta.get_edge(u, v) 215 | raise IndexError(f"The edge ({u}, {v}) is not in the graph.") 216 | 217 | def get_node_successors( 218 | self, u: Hashable, include_metadata: bool = False 219 | ) -> Generator: 220 | return self.get_node_neighbors(u, include_metadata) 221 | 222 | def get_node_neighbors( 223 | self, u: Hashable, include_metadata: bool = False 224 | ) -> Generator: 225 | """ 226 | Get a generator of all downstream nodes from this node. 227 | 228 | Arguments: 229 | u (Hashable): The source node ID 230 | 231 | Returns: 232 | Generator 233 | 234 | """ 235 | my_id = self._names.get_id(u) 236 | if include_metadata: 237 | val = {} 238 | for vid in self._nk_graph.iterNeighbors(my_id): 239 | v = self._names.get_name(vid) 240 | if self.is_directed(): 241 | val[v] = self._meta.get_edge(u, v) 242 | else: 243 | try: 244 | val[v] = self._meta.get_edge(u, v) 245 | except KeyError: 246 | val[v] = self._meta.get_edge(v, u) 247 | return val 248 | 249 | return iter( 250 | [self._names.get_name(i) for i in self._nk_graph.iterNeighbors(my_id)] 251 | ) 252 | 253 | def get_node_predecessors( 254 | self, u: Hashable, include_metadata: bool = False 255 | ) -> Generator: 256 | """ 257 | Get a generator of all upstream nodes from this node. 258 | 259 | Arguments: 260 | u (Hashable): The source node ID 261 | 262 | Returns: 263 | Generator 264 | 265 | """ 266 | my_id = self._names.get_id(u) 267 | if include_metadata: 268 | val = {} 269 | for vid in self._nk_graph.iterInNeighbors(my_id): 270 | v = self._names.get_name(vid) 271 | if self.is_directed(): 272 | val[v] = self._meta.get_edge(v, u) 273 | else: 274 | try: 275 | val[v] = self._meta.get_edge(u, v) 276 | except KeyError: 277 | val[v] = self._meta.get_edge(v, u) 278 | return val 279 | 280 | return iter( 281 | [self._names.get_name(i) for i in self._nk_graph.iterInNeighbors(my_id)] 282 | ) 283 | 284 | def get_node_count(self) -> int: 285 | """ 286 | Get an integer count of the number of nodes in this graph. 287 | 288 | Arguments: 289 | None 290 | 291 | Returns: 292 | int: The count of nodes 293 | 294 | """ 295 | return self._nk_graph.numberOfNodes() 296 | 297 | def get_edge_count(self) -> int: 298 | """ 299 | Get an integer count of the number of edges in this graph. 300 | 301 | Arguments: 302 | None 303 | 304 | Returns: 305 | int: The count of edges 306 | 307 | """ 308 | return self._nk_graph.numberOfEdges() 309 | -------------------------------------------------------------------------------- /grand/backends/_networkx.py: -------------------------------------------------------------------------------- 1 | from typing import Hashable, Collection 2 | import time 3 | 4 | import pandas as pd 5 | import networkx as nx 6 | 7 | from .backend import Backend 8 | 9 | 10 | class NetworkXBackend(Backend): 11 | def __init__(self, directed: bool = False): 12 | """ 13 | Create a new Backend instance. 14 | 15 | Arguments: 16 | directed (bool: False): Whether to make the backend graph directed 17 | 18 | Returns: 19 | None 20 | 21 | """ 22 | self._nx_graph = nx.DiGraph() if directed else nx.Graph() 23 | self._directed = directed 24 | 25 | def is_directed(self) -> bool: 26 | """ 27 | Return True if the backend graph is directed. 28 | 29 | Arguments: 30 | None 31 | 32 | Returns: 33 | bool: True if the backend graph is directed. 34 | 35 | """ 36 | return self._directed 37 | 38 | def add_node(self, node_name: Hashable, metadata: dict): 39 | """ 40 | Add a new node to the graph. 41 | 42 | Arguments: 43 | node_name (Hashable): The ID of the node 44 | metadata (dict: None): An optional dictionary of metadata 45 | 46 | Returns: 47 | Hashable: The ID of this node, as inserted 48 | 49 | """ 50 | self._nx_graph.add_node(node_name, **metadata) 51 | 52 | def get_node_by_id(self, node_name: Hashable): 53 | """ 54 | Return the data associated with a node. 55 | 56 | Arguments: 57 | node_name (Hashable): The node ID to look up 58 | 59 | Returns: 60 | dict: The metadata associated with this node 61 | 62 | """ 63 | return self._nx_graph.nodes[node_name] 64 | 65 | def all_nodes_as_iterable(self, include_metadata: bool = False) -> Collection: 66 | """ 67 | Get a generator of all of the nodes in this graph. 68 | 69 | Arguments: 70 | include_metadata (bool: False): Whether to include node metadata in 71 | the response 72 | 73 | Returns: 74 | Generator: A generator of all nodes (arbitrary sort) 75 | 76 | """ 77 | return list(self._nx_graph.nodes(data=include_metadata)) 78 | 79 | def add_edge(self, u: Hashable, v: Hashable, metadata: dict): 80 | """ 81 | Add a new edge to the graph between two nodes. 82 | 83 | If the graph is directed, this edge will start (source) at the `u` node 84 | and end (target) at the `v` node. 85 | 86 | Arguments: 87 | u (Hashable): The source node ID 88 | v (Hashable): The target node ID 89 | metadata (dict): Optional metadata to associate with the edge 90 | 91 | Returns: 92 | Hashable: The edge ID, as inserted. 93 | 94 | """ 95 | self._nx_graph.add_edge(u, v, **metadata) 96 | 97 | def all_edges_as_iterable(self, include_metadata: bool = False) -> Collection: 98 | """ 99 | Get a list of all edges in this graph, arbitrary sort. 100 | 101 | Arguments: 102 | include_metadata (bool: False): Whether to include edge metadata 103 | 104 | Returns: 105 | Generator: A generator of all edges (arbitrary sort) 106 | 107 | """ 108 | return self._nx_graph.edges(data=include_metadata) 109 | 110 | def get_edge_by_id(self, u: Hashable, v: Hashable): 111 | """ 112 | Get an edge by its source and target IDs. 113 | 114 | Arguments: 115 | u (Hashable): The source node ID 116 | v (Hashable): The target node ID 117 | 118 | Returns: 119 | dict: Metadata associated with this edge 120 | 121 | """ 122 | return self._nx_graph.edges[u, v] 123 | 124 | def get_node_neighbors( 125 | self, u: Hashable, include_metadata: bool = False 126 | ) -> Collection: 127 | """ 128 | Get a generator of all downstream nodes from this node. 129 | 130 | Arguments: 131 | u (Hashable): The source node ID 132 | 133 | Returns: 134 | Generator 135 | 136 | """ 137 | if include_metadata: 138 | return self._nx_graph[u] 139 | return self._nx_graph.neighbors(u) 140 | 141 | def get_node_predecessors( 142 | self, u: Hashable, include_metadata: bool = False 143 | ) -> Collection: 144 | """ 145 | Get a generator of all downstream nodes from this node. 146 | 147 | Arguments: 148 | u (Hashable): The source node ID 149 | 150 | Returns: 151 | Generator 152 | 153 | """ 154 | if include_metadata: 155 | return self._nx_graph.pred[u] 156 | return self._nx_graph.predecessors(u) 157 | 158 | def get_node_count(self) -> int: 159 | """ 160 | Get an integer count of the number of nodes in this graph. 161 | 162 | Arguments: 163 | None 164 | 165 | Returns: 166 | int: The count of nodes 167 | 168 | """ 169 | return len(self._nx_graph) 170 | 171 | def get_edge_count(self) -> int: 172 | """ 173 | Get an integer count of the number of edges in this graph. 174 | 175 | Arguments: 176 | None 177 | 178 | Returns: 179 | int: The count of edges 180 | 181 | """ 182 | return len(self._nx_graph.edges) 183 | 184 | def ingest_from_edgelist_dataframe( 185 | self, edgelist: pd.DataFrame, source_column: str, target_column: str 186 | ) -> dict: 187 | """ 188 | Ingest an edgelist from a Pandas DataFrame. 189 | 190 | """ 191 | 192 | tic = time.time() 193 | self._nx_graph.add_edges_from( 194 | [ 195 | ( 196 | e[source_column], 197 | e[target_column], 198 | { 199 | k: v 200 | for k, v in dict(e).items() 201 | if k not in [source_column, target_column] 202 | }, 203 | ) 204 | for _, e in edgelist.iterrows() 205 | ] 206 | ) 207 | 208 | nodes = edgelist[source_column].append(edgelist[target_column]).unique() 209 | 210 | return { 211 | "node_count": len(nodes), 212 | "node_duration": 0, 213 | "edge_count": len(edgelist), 214 | "edge_duration": time.time() - tic, 215 | } 216 | -------------------------------------------------------------------------------- /grand/backends/backend.py: -------------------------------------------------------------------------------- 1 | import cachetools.func 2 | from typing import Callable, Hashable, Collection 3 | import abc 4 | 5 | import pandas as pd 6 | 7 | 8 | class Backend(abc.ABC): 9 | """ 10 | Abstract base class for the management of persisted graph structure. 11 | 12 | Do not use this class directly. 13 | 14 | """ 15 | 16 | def __init__(self, directed: bool = False): 17 | """ 18 | Create a new Backend instance. 19 | 20 | Arguments: 21 | directed (bool: False): Whether to make the backend graph directed 22 | 23 | Returns: 24 | None 25 | 26 | """ 27 | ... 28 | 29 | def ingest_from_edgelist_dataframe( 30 | self, edgelist: pd.DataFrame, source_column: str, target_column: str 31 | ) -> None: 32 | """ 33 | Ingest an edgelist from a Pandas DataFrame. 34 | 35 | """ 36 | ... 37 | 38 | def is_directed(self) -> bool: 39 | """ 40 | Return True if the backend graph is directed. 41 | 42 | Arguments: 43 | None 44 | 45 | Returns: 46 | bool: True if the backend graph is directed. 47 | 48 | """ 49 | ... 50 | 51 | def add_node(self, node_name: Hashable, metadata: dict): 52 | """ 53 | Add a new node to the graph. 54 | 55 | Arguments: 56 | node_name (Hashable): The ID of the node 57 | metadata (dict: None): An optional dictionary of metadata 58 | upsert (bool: True): Update the node if it already exists. If this 59 | is set to False and the node already exists, a backend may 60 | choose to throw an error or proceed gracefully. 61 | 62 | Returns: 63 | Hashable: The ID of this node, as inserted 64 | 65 | """ 66 | ... 67 | 68 | def add_nodes_from(self, nodes_for_adding, **attr): 69 | """ 70 | Add nodes to the graph. 71 | 72 | Arguments: 73 | nodes_for_adding: nodes to add 74 | attr: additional attributes 75 | """ 76 | for node, metadata in nodes_for_adding: 77 | self.add_node(node, {**attr, **metadata}) 78 | 79 | def get_node_by_id(self, node_name: Hashable): 80 | """ 81 | Return the data associated with a node. 82 | 83 | Arguments: 84 | node_name (Hashable): The node ID to look up 85 | 86 | Returns: 87 | dict: The metadata associated with this node 88 | 89 | """ 90 | ... 91 | 92 | def all_nodes_as_iterable(self, include_metadata: bool = False) -> Collection: 93 | """ 94 | Get a generator of all of the nodes in this graph. 95 | 96 | Arguments: 97 | include_metadata (bool: False): Whether to include node metadata in 98 | the response 99 | 100 | Returns: 101 | Generator: A generator of all nodes (arbitrary sort) 102 | 103 | """ 104 | ... 105 | 106 | def has_node(self, u: Hashable) -> bool: 107 | """ 108 | Return true if the node exists in the graph. 109 | 110 | Arguments: 111 | u (Hashable): The ID of the node to check 112 | 113 | Returns: 114 | bool: True if the node exists 115 | """ 116 | try: 117 | return self.get_node_by_id(u) is not None 118 | except KeyError: 119 | return False 120 | 121 | def has_edge(self, u: Hashable, v: Hashable) -> bool: 122 | """ 123 | Return true if the edge exists in the graph. 124 | 125 | Arguments: 126 | u (Hashable): The source node ID 127 | v (Hashable): The target node ID 128 | 129 | Returns: 130 | bool: True if the edge exists 131 | """ 132 | try: 133 | return self.get_edge_by_id(u, v) is not None 134 | except KeyError: 135 | return False 136 | 137 | def add_edge(self, u: Hashable, v: Hashable, metadata: dict): 138 | """ 139 | Add a new edge to the graph between two nodes. 140 | 141 | If the graph is directed, this edge will start (source) at the `u` node 142 | and end (target) at the `v` node. 143 | 144 | Arguments: 145 | u (Hashable): The source node ID 146 | v (Hashable): The target node ID 147 | metadata (dict): Optional metadata to associate with the edge 148 | 149 | Returns: 150 | Hashable: The edge ID, as inserted. 151 | 152 | """ 153 | ... 154 | 155 | def add_edges_from(self, ebunch_to_add, **attr): 156 | """ 157 | Add new edges to the graph. 158 | 159 | Arguments: 160 | ebunch_to_add: list of (source, target, metadata) 161 | attr: additional common attributes 162 | """ 163 | for u, v, metadata in ebunch_to_add: 164 | self.add_edge(u, v, {**attr, **metadata}) 165 | 166 | def all_edges_as_iterable(self, include_metadata: bool = False) -> Collection: 167 | """ 168 | Get a list of all edges in this graph, arbitrary sort. 169 | 170 | Arguments: 171 | include_metadata (bool: False): Whether to include edge metadata 172 | 173 | Returns: 174 | Generator: A generator of all edges (arbitrary sort) 175 | 176 | """ 177 | ... 178 | 179 | def get_edge_by_id(self, u: Hashable, v: Hashable): 180 | """ 181 | Get an edge by its source and target IDs. 182 | 183 | Arguments: 184 | u (Hashable): The source node ID 185 | v (Hashable): The target node ID 186 | 187 | Returns: 188 | dict: Metadata associated with this edge 189 | 190 | """ 191 | ... 192 | 193 | def get_node_successors( 194 | self, u: Hashable, include_metadata: bool = False 195 | ) -> Collection: 196 | return self.get_node_neighbors(u, include_metadata) 197 | 198 | def get_node_neighbors( 199 | self, u: Hashable, include_metadata: bool = False 200 | ) -> Collection: 201 | """ 202 | Get a generator of all downstream nodes from this node. 203 | 204 | Arguments: 205 | u (Hashable): The source node ID 206 | 207 | Returns: 208 | Generator 209 | 210 | """ 211 | ... 212 | 213 | def get_node_predecessors( 214 | self, u: Hashable, include_metadata: bool = False 215 | ) -> Collection: 216 | """ 217 | Get a generator of all upstream nodes from this node. 218 | 219 | Arguments: 220 | u (Hashable): The source node ID 221 | 222 | Returns: 223 | Generator 224 | 225 | """ 226 | ... 227 | 228 | def get_node_count(self) -> int: 229 | """ 230 | Get an integer count of the number of nodes in this graph. 231 | 232 | Arguments: 233 | None 234 | 235 | Returns: 236 | int: The count of nodes 237 | 238 | """ 239 | return len([i for i in self.all_nodes_as_iterable()]) 240 | 241 | def get_edge_count(self) -> int: 242 | """ 243 | Get an integer count of the number of edges in this graph. 244 | 245 | Arguments: 246 | None 247 | 248 | Returns: 249 | int: The count of edges 250 | 251 | """ 252 | return len([i for i in self.all_edges_as_iterable()]) 253 | 254 | def degree(self, u: Hashable) -> int: 255 | """ 256 | Get the degree of a node. 257 | 258 | Arguments: 259 | u (Hashable): The node ID 260 | 261 | Returns: 262 | int: The degree of the node 263 | 264 | """ 265 | if self.is_directed(): 266 | # For directed graphs, degree = in_degree + out_degree 267 | return self.in_degree(u) + self.out_degree(u) 268 | else: 269 | # For undirected graphs, count all neighbors 270 | return len([i for i in self.get_node_neighbors(u)]) 271 | 272 | def degrees(self, nbunch=None) -> Collection: 273 | return { 274 | node: self.degree(node) for node in (nbunch or self.all_nodes_as_iterable()) 275 | } 276 | 277 | def in_degree(self, u: Hashable) -> int: 278 | """ 279 | Get the in-degree of a node. 280 | 281 | Arguments: 282 | u (Hashable): The node ID 283 | 284 | Returns: 285 | int: The in-degree of the node 286 | 287 | """ 288 | return len(list(self.get_node_predecessors(u))) 289 | 290 | def in_degrees(self, nbunch=None) -> Collection: 291 | nbunch = nbunch or self.all_nodes_as_iterable() 292 | if isinstance(nbunch, (list, tuple)): 293 | return {node: self.in_degree(node) for node in nbunch} 294 | else: 295 | return self.in_degree(nbunch) 296 | 297 | def out_degree(self, u: Hashable) -> int: 298 | """ 299 | Get the out-degree of a node. 300 | 301 | Arguments: 302 | u (Hashable): The node ID 303 | 304 | Returns: 305 | int: The out-degree of the node 306 | 307 | """ 308 | return len(list(self.get_node_successors(u))) 309 | 310 | def out_degrees(self, nbunch=None) -> Collection: 311 | nbunch = nbunch or self.all_nodes_as_iterable() 312 | if isinstance(nbunch, (list, tuple)): 313 | return {node: self.out_degree(node) for node in nbunch} 314 | else: 315 | return self.out_degree(nbunch) 316 | 317 | 318 | class CachedBackend(Backend): 319 | """ 320 | A proxy Backend that serves as a cache for any other grand.Backend. 321 | 322 | """ 323 | 324 | def __init__(self, backend: Backend): ... 325 | 326 | 327 | class InMemoryCachedBackend(CachedBackend): 328 | """ 329 | A proxy Backend that serves as a cache for any other grand.Backend. 330 | 331 | Wraps each call to the Backend with an LRU cache. 332 | 333 | """ 334 | 335 | _cache_types = { 336 | "LRUCache": cachetools.func.lru_cache, 337 | "TTLCache": cachetools.func.ttl_cache, 338 | "LFUCache": cachetools.func.lfu_cache, 339 | } 340 | 341 | _default_uncacheable_methods = [ 342 | "add_node", 343 | "add_nodes_from", 344 | "add_edge", 345 | "add_edges_from", 346 | "ingest_from_edgelist_dataframe", 347 | "remove_node", 348 | ] 349 | 350 | _default_write_methods = [ 351 | "add_node", 352 | "add_nodes_from", 353 | "add_edge", 354 | "add_edges_from", 355 | "ingest_from_edgelist_dataframe", 356 | "remove_node", 357 | ] 358 | 359 | def __init__( 360 | self, 361 | backend: Backend, 362 | dirty_cache_on_write: bool = True, 363 | cache_type: str = "TTLCache", 364 | uncacheable_methods: list = None, 365 | write_methods: list = None, 366 | **cache_kwargs, 367 | ): 368 | """ 369 | Initialize a new in-memory cache, using the cachetools library. 370 | 371 | Arguments: 372 | backend (grand.Backend): The backend to cache 373 | dirty_cache_on_write (bool): Whether to clear the cache on writes 374 | cache_type (str: "TTLCache"): The cache type to use. One of 375 | ["LRUCache", "TTLCache"] 376 | **cache_kwargs: Additional arguments to pass to the cache 377 | 378 | 379 | """ 380 | self.backend = backend 381 | self._dirty_cache_on_write = dirty_cache_on_write 382 | self._uncacheable_methods = ( 383 | uncacheable_methods or self._default_uncacheable_methods 384 | ) 385 | self._write_methods = write_methods or self._default_write_methods 386 | if cache_type not in self._cache_types: 387 | raise ValueError( 388 | f"Unknown cache type: {cache_type}. " 389 | f"Valid types are: {self._cache_types.keys()}" 390 | ) 391 | self._cache_factory = lambda: self._cache_types[cache_type](**cache_kwargs) 392 | 393 | self._method_lookup = {} 394 | 395 | def _dirty_cache_decorator(method_: Callable): 396 | def dirty_cache_dec_wrapper(*args, **kwargs): 397 | self.clear_cache() 398 | return method_(*args, **kwargs) 399 | 400 | return dirty_cache_dec_wrapper 401 | 402 | method_list = [ 403 | attribute 404 | for attribute in dir(self.backend) 405 | if callable(getattr(self.backend, attribute)) 406 | and not attribute.startswith("_") 407 | ] 408 | for method in method_list: 409 | if method in self._uncacheable_methods: 410 | setattr(self, method, getattr(self.backend, method)) 411 | else: 412 | wrapped = self._wrapped(method) 413 | self._method_lookup[method] = wrapped 414 | setattr(self, method, wrapped) 415 | 416 | if self._dirty_cache_on_write and method in self._write_methods: 417 | setattr( 418 | self, method, _dirty_cache_decorator(getattr(self.backend, method)) 419 | ) 420 | 421 | def _wrapped(self, method: str) -> Callable: 422 | c = self._cache_factory()(getattr(self.backend, method)) 423 | return c 424 | 425 | def clear_cache(self): 426 | """ 427 | Clear the cache. 428 | 429 | """ 430 | for _, method in self._method_lookup.items(): 431 | method.cache_clear() 432 | 433 | def cache_info(self): 434 | return { 435 | method_name: method.cache_info() 436 | for method_name, method in self._method_lookup.items() 437 | } 438 | -------------------------------------------------------------------------------- /grand/backends/metadatastore.py: -------------------------------------------------------------------------------- 1 | from typing import Hashable 2 | 3 | 4 | class MetadataStore: 5 | def add_node(self, node_name: Hashable, metadata: dict) -> Hashable: 6 | raise NotImplementedError() 7 | 8 | def add_edge(self, u: Hashable, v: Hashable, metadata: dict) -> Hashable: 9 | raise NotImplementedError() 10 | 11 | def get_node(self, node_name: Hashable) -> dict: 12 | raise NotImplementedError() 13 | 14 | def get_edge(self, u: Hashable, v: Hashable) -> dict: 15 | raise NotImplementedError() 16 | 17 | 18 | class DictMetadataStore(MetadataStore): 19 | def __init__(self): 20 | self.ndata = {} 21 | self.edata = {} 22 | 23 | def add_node(self, node_name: Hashable, metadata: dict) -> Hashable: 24 | if metadata is None: 25 | metadata = {} 26 | # Add or update metadata 27 | if node_name in self.ndata: 28 | self.ndata[node_name].update(metadata) 29 | else: 30 | self.ndata[node_name] = metadata 31 | 32 | def add_edge(self, u: Hashable, v: Hashable, metadata: dict) -> Hashable: 33 | if metadata is None: 34 | metadata = {} 35 | # Add or update metadata 36 | if (u, v) in self.edata: 37 | self.edata[(u, v)].update(metadata) 38 | else: 39 | self.edata[(u, v)] = metadata 40 | 41 | def get_node(self, node_name: Hashable) -> dict: 42 | return self.ndata[node_name] 43 | 44 | def get_edge(self, u: Hashable, v: Hashable) -> dict: 45 | return self.edata[(u, v)] 46 | 47 | 48 | class NodeNameManager: 49 | def __init__(self): 50 | self.node_names_by_id = {} 51 | self.node_ids_by_name = {} 52 | 53 | def add_node(self, name: Hashable, _id: Hashable): 54 | self.node_names_by_id[_id] = name 55 | self.node_ids_by_name[name] = _id 56 | 57 | def get_name(self, _id: Hashable) -> Hashable: 58 | return self.node_names_by_id[_id] 59 | 60 | def get_id(self, name: Hashable) -> Hashable: 61 | return self.node_ids_by_name[name] 62 | 63 | def __contains__(self, name: Hashable) -> bool: 64 | return name in self.node_ids_by_name 65 | -------------------------------------------------------------------------------- /grand/backends/test_backends.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | import os 3 | import pandas as pd 4 | 5 | import networkx as nx 6 | 7 | from . import NetworkXBackend, DataFrameBackend 8 | 9 | try: 10 | from ._dynamodb import DynamoDBBackend 11 | 12 | _CAN_IMPORT_DYNAMODB = True 13 | except ImportError: 14 | _CAN_IMPORT_DYNAMODB = False 15 | 16 | try: 17 | from ._igraph import IGraphBackend 18 | 19 | _CAN_IMPORT_IGRAPH = True 20 | except ImportError: 21 | _CAN_IMPORT_IGRAPH = False 22 | 23 | try: 24 | from ._networkit import NetworkitBackend 25 | 26 | _CAN_IMPORT_NETWORKIT = True 27 | except ImportError: 28 | _CAN_IMPORT_NETWORKIT = False 29 | 30 | try: 31 | from ._sqlbackend import SQLBackend 32 | 33 | _CAN_IMPORT_SQL = True 34 | except ImportError: 35 | _CAN_IMPORT_SQL = False 36 | 37 | from .. import Graph 38 | 39 | 40 | backend_test_params = [ 41 | pytest.param( 42 | (NetworkXBackend, {}), 43 | marks=pytest.mark.skipif( 44 | os.environ.get("TEST_NETWORKXBACKEND", default="1") != "1", 45 | reason="NetworkX Backend skipped because $TEST_NETWORKXBACKEND != 1.", 46 | ), 47 | id="NetworkXBackend", 48 | ), 49 | ] 50 | backend_test_params = [ 51 | pytest.param( 52 | (DataFrameBackend, {}), 53 | marks=pytest.mark.skipif( 54 | os.environ.get("TEST_DATAFRAMEBACKEND", default="1") != "1", 55 | reason="DataFrameBackend skipped because $TEST_DATAFRAMEBACKEND != 1.", 56 | ), 57 | id="DataFrameBackend", 58 | ), 59 | ] 60 | 61 | if _CAN_IMPORT_DYNAMODB: 62 | backend_test_params.append( 63 | pytest.param( 64 | (DynamoDBBackend, {}), 65 | marks=pytest.mark.skipif( 66 | os.environ.get("TEST_DYNAMODB", default="1") != "1", 67 | reason="DynamoDB Backend skipped because $TEST_DYNAMODB != 0 or boto3 is not installed", 68 | ), 69 | id="DynamoDBBackend", 70 | ), 71 | ) 72 | 73 | if _CAN_IMPORT_SQL: 74 | backend_test_params.append( 75 | pytest.param( 76 | (SQLBackend, {"db_url": "sqlite:///:memory:"}), 77 | marks=pytest.mark.skipif( 78 | os.environ.get("TEST_SQLBACKEND", default="1") != "1", 79 | reason="SQL Backend skipped because $TEST_SQLBACKEND != 1 or sqlalchemy is not installed.", 80 | ), 81 | id="SQLBackend", 82 | ), 83 | ) 84 | if _CAN_IMPORT_IGRAPH: 85 | backend_test_params.append( 86 | pytest.param( 87 | (IGraphBackend, {}), 88 | marks=pytest.mark.skipif( 89 | os.environ.get("TEST_IGRAPHBACKEND", default="1") != "1", 90 | reason="IGraph Backend skipped because $TEST_IGRAPHBACKEND != 1 or igraph is not installed.", 91 | ), 92 | id="IGraphBackend", 93 | ), 94 | ) 95 | if _CAN_IMPORT_NETWORKIT: 96 | backend_test_params.append( 97 | pytest.param( 98 | (NetworkitBackend, {}), 99 | marks=pytest.mark.skipif( 100 | os.environ.get("TEST_NETWORKIT", default="1") != "1", 101 | reason="Networkit Backend skipped because $TEST_NETWORKIT != 1 or networkit is not installed.", 102 | ), 103 | id="NetworkitBackend", 104 | ), 105 | ) 106 | 107 | if os.environ.get("TEST_NETWORKITBACKEND") == "1": 108 | from ._networkit import NetworkitBackend 109 | 110 | backend_test_params.append( 111 | pytest.param( 112 | (NetworkitBackend, {}), 113 | marks=pytest.mark.skipif( 114 | os.environ.get("TEST_NETWORKITBACKEND") != "1", 115 | reason="Networkit Backend skipped because $TEST_NETWORKITBACKEND != 1.", 116 | ), 117 | id="NetworkitBackend", 118 | ), 119 | ) 120 | 121 | if os.environ.get("TEST_IGRAPHBACKEND") == "1": 122 | from ._igraph import IGraphBackend 123 | 124 | backend_test_params.append( 125 | pytest.param( 126 | (IGraphBackend, {}), 127 | marks=pytest.mark.skipif( 128 | os.environ.get("TEST_IGRAPHBACKEND") != "1", 129 | reason="Networkit Backend skipped because $TEST_IGRAPHBACKEND != 1.", 130 | ), 131 | id="IGraphBackend", 132 | ), 133 | ) 134 | 135 | 136 | # @pytest.mark.parametrize("backend", backend_test_params) 137 | class TestBackendPersistence: 138 | def test_sqlite_persistence(self): 139 | if not _CAN_IMPORT_SQL: 140 | return 141 | 142 | dbpath = "grand_peristence_test_temp.db" 143 | url = "sqlite:///" + dbpath 144 | 145 | # arrange 146 | backend = SQLBackend(db_url=url, directed=True) 147 | node0 = backend.add_node("A", {"foo": "bar"}) 148 | backend.commit() 149 | backend.close() 150 | # act 151 | backend = SQLBackend(db_url=url, directed=True) 152 | nodes = list(backend.all_nodes_as_iterable()) 153 | # assert 154 | assert node0 in nodes 155 | 156 | # test remove_node 157 | backend = SQLBackend(db_url=url, directed=True) 158 | node1, node2 = backend.add_node("A", {}), backend.add_node("B", {}) 159 | backend.add_edge(node1, node2, {}) 160 | assert backend.has_node(node1) 161 | assert backend.has_edge(node1, node2) 162 | backend.remove_node(node1) 163 | assert not backend.has_node(node1) 164 | assert not backend.has_edge(node1, node2) 165 | with pytest.raises(KeyError): 166 | assert not backend.get_node_by_id(node1) 167 | 168 | # cleanup 169 | os.remove(dbpath) 170 | 171 | 172 | @pytest.mark.parametrize("backend", backend_test_params) 173 | class TestBackend: 174 | def test_can_create(self, backend): 175 | backend, kwargs = backend 176 | backend(**kwargs) 177 | 178 | def test_can_create_directed_and_undirected_backends(self, backend): 179 | backend, kwargs = backend 180 | b = backend(directed=True, **kwargs) 181 | assert b.is_directed() == True 182 | 183 | b = backend(directed=False, **kwargs) 184 | assert b.is_directed() == False 185 | 186 | def test_can_add_node(self, backend): 187 | backend, kwargs = backend 188 | G = Graph(backend=backend(**kwargs)) 189 | nxG = nx.Graph() 190 | G.nx.add_node("A", k="v") 191 | nxG.add_node("A", k="v") 192 | assert len(G.nx.nodes()) == len(nxG.nodes()) 193 | G.nx.add_node("B", k="v") 194 | nxG.add_node("B", k="v") 195 | assert len(G.nx.nodes()) == len(nxG.nodes()) 196 | 197 | def test_can_update_node(self, backend): 198 | backend, kwargs = backend 199 | G = Graph(backend=backend(**kwargs)) 200 | G.nx.add_node("A", k="v", z=3) 201 | G.nx.add_node("A", k="v2", x=4) 202 | assert G.nx.nodes["A"]["k"] == "v2" 203 | assert G.nx.nodes["A"]["x"] == 4 204 | assert G.nx.nodes["A"]["z"] == 3 205 | 206 | def test_can_add_edge(self, backend): 207 | backend, kwargs = backend 208 | G = Graph(backend=backend(**kwargs)) 209 | nxG = nx.Graph() 210 | G.nx.add_edge("A", "B") 211 | nxG.add_edge("A", "B") 212 | assert len(G.nx.edges()) == len(nxG.edges()) 213 | G.nx.add_edge("A", "B") 214 | nxG.add_edge("A", "B") 215 | assert len(G.nx.edges()) == len(nxG.edges()) 216 | 217 | def test_can_update_edge(self, backend): 218 | backend, kwargs = backend 219 | G = Graph(backend=backend(**kwargs)) 220 | G.nx.add_edge("A", "B", k="v", z=3) 221 | G.nx.add_edge("A", "B", k="v2", x=4) 222 | assert G.nx.get_edge_data("A", "B")["k"] == "v2" 223 | assert G.nx.get_edge_data("A", "B")["x"] == 4 224 | assert G.nx.get_edge_data("A", "B")["z"] == 3 225 | assert len(G.nx.nodes()) == 2 226 | 227 | def test_can_get_node(self, backend): 228 | backend, kwargs = backend 229 | G = Graph(backend=backend(**kwargs)) 230 | nxG = nx.Graph() 231 | md = dict(k="B") 232 | G.nx.add_node("A", **md) 233 | nxG.add_node("A", **md) 234 | assert G.nx.nodes["A"] == nxG.nodes["A"] 235 | 236 | def test_can_get_edge(self, backend): 237 | backend, kwargs = backend 238 | G = Graph(backend=backend(**kwargs)) 239 | nxG = nx.Graph() 240 | md = {"k": "B"} 241 | G.nx.add_edge("A", "B", **md) 242 | nxG.add_edge("A", "B", **md) 243 | assert G.nx.get_edge_data("A", "B") == nxG.get_edge_data("A", "B") 244 | 245 | def test_can_get_neighbors(self, backend): 246 | backend, kwargs = backend 247 | G = Graph(backend=backend(**kwargs)) 248 | nxG = nx.Graph() 249 | G.nx.add_edge("A", "B") 250 | nxG.add_edge("A", "B") 251 | assert sorted([i for i in G.nx.neighbors("A")]) == sorted( 252 | [i for i in nxG.neighbors("A")] 253 | ) 254 | assert sorted([i for i in G.nx.neighbors("B")]) == sorted( 255 | [i for i in nxG.neighbors("B")] 256 | ) 257 | G.nx.add_edge("A", "C") 258 | nxG.add_edge("A", "C") 259 | assert sorted([i for i in G.nx.neighbors("A")]) == sorted( 260 | [i for i in nxG.neighbors("A")] 261 | ) 262 | assert sorted([i for i in G.nx.neighbors("B")]) == sorted( 263 | [i for i in nxG.neighbors("B")] 264 | ) 265 | assert sorted([i for i in G.nx.neighbors("C")]) == sorted( 266 | [i for i in nxG.neighbors("C")] 267 | ) 268 | 269 | def test_undirected_adj(self, backend): 270 | backend, kwargs = backend 271 | G = Graph(backend=backend(**kwargs)) 272 | nxG = nx.Graph() 273 | assert G.nx._adj == nxG._adj 274 | G.nx.add_edge("A", "B") 275 | nxG.add_edge("A", "B") 276 | assert G.nx._adj == nxG._adj 277 | 278 | def test_directed_adj(self, backend): 279 | backend, kwargs = backend 280 | G = Graph(backend=backend(directed=True, **kwargs)) 281 | nxG = nx.DiGraph() 282 | assert G.nx._adj == nxG._adj 283 | G.nx.add_edge("A", "B") 284 | nxG.add_edge("A", "B") 285 | assert G.nx._adj == nxG._adj 286 | 287 | def test_can_traverse_undirected_graph(self, backend): 288 | backend, kwargs = backend 289 | G = Graph(backend=backend(**kwargs)) 290 | nxG = nx.Graph() 291 | md = dict(k="B") 292 | G.nx.add_edge("A", "B", **md) 293 | nxG.add_edge("A", "B", **md) 294 | assert dict(nx.bfs_successors(G.nx, "A")) == dict(nx.bfs_successors(nxG, "A")) 295 | G.nx.add_edge("B", "C", **md) 296 | nxG.add_edge("B", "C", **md) 297 | assert dict(nx.bfs_successors(G.nx, "A")) == dict(nx.bfs_successors(nxG, "A")) 298 | G.nx.add_edge("B", "D", **md) 299 | nxG.add_edge("B", "D", **md) 300 | assert dict(nx.bfs_successors(G.nx, "A")) == dict(nx.bfs_successors(nxG, "A")) 301 | assert dict(nx.bfs_successors(G.nx, "C")) == dict(nx.bfs_successors(nxG, "C")) 302 | 303 | def test_can_traverse_directed_graph(self, backend): 304 | backend, kwargs = backend 305 | G = Graph(backend=backend(directed=True, **kwargs)) 306 | nxG = nx.DiGraph() 307 | md = dict(k="B") 308 | G.nx.add_edge("A", "B", **md) 309 | nxG.add_edge("A", "B", **md) 310 | assert dict(nx.bfs_successors(G.nx, "A")) == dict(nx.bfs_successors(nxG, "A")) 311 | G.nx.add_edge("B", "C", **md) 312 | nxG.add_edge("B", "C", **md) 313 | assert dict(nx.bfs_successors(G.nx, "A")) == dict(nx.bfs_successors(nxG, "A")) 314 | G.nx.add_edge("B", "D", **md) 315 | nxG.add_edge("B", "D", **md) 316 | assert dict(nx.bfs_successors(G.nx, "A")) == dict(nx.bfs_successors(nxG, "A")) 317 | assert dict(nx.bfs_successors(G.nx, "C")) == dict(nx.bfs_successors(nxG, "C")) 318 | 319 | def test_subgraph_isomorphism_undirected(self, backend): 320 | backend, kwargs = backend 321 | G = Graph(backend=backend(directed=False, **kwargs)) 322 | nxG = nx.Graph() 323 | 324 | G.nx.add_edge("A", "B") 325 | nxG.add_edge("A", "B") 326 | G.nx.add_edge("B", "C") 327 | nxG.add_edge("B", "C") 328 | G.nx.add_edge("C", "A") 329 | nxG.add_edge("C", "A") 330 | 331 | from networkx.algorithms.isomorphism import GraphMatcher 332 | 333 | assert len( 334 | [i for i in GraphMatcher(G.nx, G.nx).subgraph_monomorphisms_iter()] 335 | ) == len([i for i in GraphMatcher(nxG, nxG).subgraph_monomorphisms_iter()]) 336 | 337 | def test_subgraph_isomorphism_directed(self, backend): 338 | backend, kwargs = backend 339 | G = Graph(backend=backend(directed=True, **kwargs)) 340 | nxG = nx.DiGraph() 341 | 342 | G.nx.add_edge("A", "B") 343 | nxG.add_edge("A", "B") 344 | G.nx.add_edge("B", "C") 345 | nxG.add_edge("B", "C") 346 | G.nx.add_edge("C", "A") 347 | nxG.add_edge("C", "A") 348 | 349 | from networkx.algorithms.isomorphism import DiGraphMatcher 350 | 351 | assert len( 352 | [i for i in DiGraphMatcher(G.nx, G.nx).subgraph_monomorphisms_iter()] 353 | ) == len([i for i in DiGraphMatcher(nxG, nxG).subgraph_monomorphisms_iter()]) 354 | 355 | def test_can_get_edge_metadata(self, backend): 356 | backend, kwargs = backend 357 | G = Graph(backend=backend(**kwargs)) 358 | G.nx.add_edge("foo", "bar", baz=True) 359 | assert list(G.nx.edges(data=True)) == [("foo", "bar", {"baz": True})] 360 | 361 | def test_can_get_edges(self, backend): 362 | backend, kwargs = backend 363 | G = Graph(backend=backend(**kwargs)) 364 | G.nx.add_edge("foo", "bar", baz=True) 365 | assert list(G.backend.all_edges_as_iterable()) == [("foo", "bar")] 366 | 367 | def test_edge_dne_raises(self, backend): 368 | backend, kwargs = backend 369 | G = Graph(backend=backend(**kwargs)) 370 | G.nx.add_edge("foo", "bar", baz=True) 371 | 372 | assert G.nx.has_edge("foo", "crab") == False 373 | assert G.nx.has_edge("foo", "bar") == True 374 | # assert G.nx.edges[("foo", "bar")] != None 375 | # with pytest.raises(Exception): 376 | # G.nx.edges[("foo", "crab")] 377 | 378 | def test_reverse_edges_in_undirected(self, backend): 379 | backend, kwargs = backend 380 | G = Graph(backend=backend(directed=False, **kwargs)) 381 | G.nx.add_edge("foo", "bar", baz=True) 382 | 383 | assert G.nx.has_edge("foo", "bar") == True 384 | assert G.nx.has_edge("bar", "foo") == True 385 | 386 | def test_undirected_degree(self, backend): 387 | backend, kwargs = backend 388 | G = Graph(backend=backend(directed=False, **kwargs)) 389 | G.nx.add_edge("foo", "bar", baz=True) 390 | assert G.nx.degree("foo") == 1 391 | assert G.nx.degree("bar") == 1 392 | 393 | def test_directed_degree(self, backend): 394 | backend, kwargs = backend 395 | G = Graph(backend=backend(directed=True, **kwargs)) 396 | G.nx.add_edge("foo", "bar", baz=True) 397 | assert G.nx.degree("foo") == 1 398 | assert G.nx.degree("bar") == 1 399 | assert G.nx.in_degree("bar") == 1 400 | assert G.nx.out_degree("bar") == 0 401 | assert G.nx.in_degree("foo") == 0 402 | assert G.nx.out_degree("foo") == 1 403 | 404 | def test_undirected_degree_multiple(self, backend): 405 | backend, kwargs = backend 406 | G = Graph(backend=backend(directed=False, **kwargs)) 407 | G.nx.add_edge("foo", "bar", baz=True) 408 | G.nx.add_edge("foo", "baz", baz=True) 409 | assert G.nx.degree("foo") == 2 410 | assert G.nx.degree("bar") == 1 411 | assert G.nx.degree("baz") == 1 412 | 413 | def test_directed_degree_multiple(self, backend): 414 | backend, kwargs = backend 415 | G = Graph(backend=backend(directed=True, **kwargs)) 416 | G.nx.add_edge("foo", "bar", baz=True) 417 | G.nx.add_edge("foo", "baz", baz=True) 418 | assert G.nx.degree("foo") == 2 419 | assert G.nx.degree("bar") == 1 420 | assert G.nx.degree("baz") == 1 421 | assert G.nx.out_degree("foo") == 2 422 | assert G.nx.out_degree("bar") == 0 423 | assert G.nx.out_degree("baz") == 0 424 | assert G.nx.in_degree("foo") == 0 425 | assert G.nx.in_degree("bar") == 1 426 | assert G.nx.in_degree("baz") == 1 427 | 428 | def test_node_count(self, backend): 429 | backend, kwargs = backend 430 | G = Graph(backend=backend(**kwargs)) 431 | G.nx.add_node("foo", bar=True) 432 | G.nx.add_node("bar", foo=True) 433 | assert len(G.nx) == 2 434 | 435 | 436 | @pytest.mark.benchmark 437 | @pytest.mark.parametrize("backend", backend_test_params) 438 | def test_node_addition_performance(backend): 439 | backend, kwargs = backend 440 | G = Graph(backend=backend(directed=True, **kwargs)) 441 | for i in range(1000): 442 | G.nx.add_node(i) 443 | assert len(G.nx) == 1000 444 | 445 | 446 | @pytest.mark.benchmark 447 | @pytest.mark.parametrize("backend", backend_test_params) 448 | def test_get_density_performance(backend): 449 | backend, kwargs = backend 450 | G = Graph(backend=backend(directed=True, **kwargs)) 451 | for i in range(1000): 452 | G.nx.add_node(i) 453 | for i in range(1000 - 1): 454 | G.nx.add_edge(i, i + 1) 455 | assert nx.density(G.nx) <= 0.005 456 | 457 | 458 | class TestDataFrameBackend: 459 | def test_can_create_empty(self): 460 | b = DataFrameBackend() 461 | assert b.get_edge_count() == 0 462 | assert b.get_node_count() == 0 463 | 464 | b.add_edge("A", "B", {}) 465 | assert b.get_edge_count() == 1 466 | assert b.get_node_count() == 2 467 | 468 | def test_can_create_from_int_dataframes(self): 469 | # Create an edges DataFrame 470 | edges = pd.DataFrame( 471 | { 472 | "source": [0, 1, 2, 3, 4], 473 | "target": [1, 2, 3, 4, 0], 474 | "weight": [1, 2, 3, 4, 5], 475 | } 476 | ) 477 | 478 | nodes = pd.DataFrame( 479 | { 480 | "name": [0, 1, 2, 3, 4], 481 | "value": [1, 2, 3, 4, 5], 482 | } 483 | ) 484 | 485 | b = DataFrameBackend(edge_df=edges, node_df=nodes) 486 | assert b.get_edge_count() == 5 487 | assert b.get_node_count() == 5 488 | -------------------------------------------------------------------------------- /grand/backends/test_cached_backend.py: -------------------------------------------------------------------------------- 1 | import time 2 | from .backend import InMemoryCachedBackend 3 | from ._networkx import NetworkXBackend 4 | from ._sqlbackend import SQLBackend 5 | 6 | 7 | def test_can_create_cached_backend(): 8 | InMemoryCachedBackend(NetworkXBackend(), maxsize=1024, ttl=20) 9 | 10 | 11 | def test_can_add_node(): 12 | vanilla = NetworkXBackend() 13 | cached = InMemoryCachedBackend(vanilla, maxsize=1024, ttl=20) 14 | assert vanilla.get_node_count() == 0 15 | assert cached.get_node_count() == 0 16 | cached.add_node("a", {}) 17 | assert vanilla.get_node_count() == 1 18 | assert cached.get_node_count() == 1 19 | 20 | 21 | def test_added_node_ignored_when_no_dirty_on_write(): 22 | vanilla = NetworkXBackend() 23 | cached = InMemoryCachedBackend( 24 | vanilla, dirty_cache_on_write=False, maxsize=1024, ttl=20 25 | ) 26 | assert vanilla.get_node_count() == 0 27 | assert cached.get_node_count() == 0 28 | cached.add_node("a", {}) 29 | assert vanilla.get_node_count() == 1 30 | assert cached.get_node_count() == 0 31 | 32 | 33 | def test_can_add_nodes(): 34 | cached = InMemoryCachedBackend(NetworkXBackend(), maxsize=1024, ttl=20) 35 | assert cached.get_node_count() == 0 36 | cached.add_node("a", {}) 37 | assert cached.get_node_count() == 1 38 | cached.add_node("a", {}) 39 | assert cached.get_node_count() == 1 40 | cached.add_node("b", {}) 41 | assert cached.get_node_count() == 2 42 | 43 | 44 | def test_cache_is_faster_than_no_cache(): 45 | cached = InMemoryCachedBackend(SQLBackend(), maxsize=1024, ttl=20) 46 | for i in range(1000): 47 | cached.add_node(i, {}) 48 | 49 | tic = time.time() 50 | node_count = cached.get_node_count() 51 | toc = time.time() 52 | 53 | tic2 = time.time() 54 | node_count2 = cached.get_node_count() 55 | toc2 = time.time() 56 | 57 | # Dirty the cache: 58 | cached.add_node(1000, {}) 59 | 60 | tic3 = time.time() 61 | node_count3 = cached.get_node_count() 62 | toc3 = time.time() 63 | 64 | assert node_count == node_count2 == (node_count3 - 1) 65 | assert (toc - tic) > (toc2 - tic2) 66 | assert (toc3 - tic3) > (toc2 - tic2) 67 | 68 | 69 | def test_cache_info(): 70 | cached = InMemoryCachedBackend(SQLBackend(), maxsize=1024, ttl=20) 71 | cached.add_node("foo", {}) 72 | assert cached.cache_info()["get_node_count"].misses == 0 73 | assert cached.cache_info()["get_node_count"].hits == 0 74 | 75 | cached.get_node_count() 76 | 77 | assert cached.cache_info()["get_node_count"].misses == 1 78 | assert cached.cache_info()["get_node_count"].hits == 0 79 | 80 | cached.get_node_count() 81 | 82 | assert cached.cache_info()["get_node_count"].misses == 1 83 | assert cached.cache_info()["get_node_count"].hits == 1 84 | -------------------------------------------------------------------------------- /grand/backends/test_metadatastore.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from .metadatastore import DictMetadataStore, NodeNameManager 3 | 4 | 5 | def test_can_create_dict_metadata_store(): 6 | store = DictMetadataStore() 7 | assert store is not None 8 | 9 | 10 | def test_can_add_to_dict_metadata_store(): 11 | store = DictMetadataStore() 12 | store.add_node("a", {"a": 1}) 13 | assert store.get_node("a") == {"a": 1} 14 | 15 | 16 | def test_can_add_edge(): 17 | store = DictMetadataStore() 18 | store.add_edge("a", "b", {"b": 2}) 19 | assert store.get_edge("a", "b") == {"b": 2} 20 | 21 | 22 | def test_can_update_edge(): 23 | store = DictMetadataStore() 24 | store.add_edge("a", "b", {"b": 2}) 25 | store.add_edge("a", "b", {"x": "x"}) 26 | store.add_edge("a", "b", {"z": "z"}) 27 | assert store.get_edge("a", "b")["b"] == 2 28 | assert store.get_edge("a", "b")["x"] == "x" 29 | assert store.get_edge("a", "b")["z"] == "z" 30 | 31 | 32 | def test_cannot_get_invalid_node(): 33 | store = DictMetadataStore() 34 | with pytest.raises(KeyError): 35 | store.get_node("a") 36 | 37 | 38 | def test_can_create_dict_name_manager(): 39 | store = NodeNameManager() 40 | assert store is not None 41 | 42 | 43 | def test_can_add_name_manager(): 44 | store = NodeNameManager() 45 | store.add_node("a", 1) 46 | assert "a" in store 47 | assert 1 not in store 48 | assert store.get_name(1) == "a" 49 | 50 | 51 | def test_can_reverse_lookup_node_name_manager(): 52 | store = NodeNameManager() 53 | store.add_node("a", 1) 54 | assert "a" in store 55 | assert 1 not in store 56 | assert store.get_id("a") == 1 57 | -------------------------------------------------------------------------------- /grand/dialects/__init__.py: -------------------------------------------------------------------------------- 1 | from typing import Hashable, Generator, List, Tuple, Union 2 | from typing import TYPE_CHECKING 3 | 4 | if TYPE_CHECKING: 5 | from .. import Graph 6 | 7 | import pandas as pd 8 | 9 | import networkx as nx 10 | from networkx.classes.reportviews import NodeView 11 | from networkx.classes.coreviews import AdjacencyView, AtlasView 12 | 13 | 14 | class _GrandAdjacencyView(AdjacencyView): 15 | __slots__ = ("_parent", "_pred_or_succ") # Still uses AtlasView slots names _atlas 16 | 17 | def __init__(self, parent_nx_dialect: "NetworkXDialect", pred_or_succ: str): 18 | self._parent = parent_nx_dialect.parent 19 | self._pred_or_succ = pred_or_succ 20 | 21 | def __getitem__(self, name): 22 | if self._pred_or_succ == "pred": 23 | return { 24 | neighbor: metadata 25 | for neighbor, metadata in self._parent.backend.get_node_predecessors( 26 | name, include_metadata=True 27 | ).items() 28 | } 29 | elif self._pred_or_succ == "succ": 30 | return { 31 | neighbor: metadata 32 | for neighbor, metadata in self._parent.backend.get_node_successors( 33 | name, include_metadata=True 34 | ).items() 35 | } 36 | 37 | def __len__(self): 38 | return self._parent.backend.get_node_count() 39 | 40 | def __iter__(self): 41 | return iter(self._parent.backend.all_nodes_as_iterable(include_metadata=False)) 42 | 43 | def copy(self): 44 | raise NotImplementedError() 45 | 46 | def __str__(self): 47 | return "_GrandAdjacencyView" 48 | 49 | def __repr__(self): 50 | return "_GrandAdjacencyView" 51 | 52 | 53 | class _GrandNodeAtlasView(AtlasView): 54 | def __init__(self, parent): 55 | self.parent = parent.parent 56 | 57 | def __getitem__(self, key): 58 | return self.parent.backend.get_node_by_id(key) 59 | 60 | def __len__(self): 61 | return self.parent.backend.get_node_count() 62 | 63 | def __iter__(self): 64 | return iter(self.parent.backend.all_nodes_as_iterable(include_metadata=False)) 65 | 66 | def copy(self): 67 | return { 68 | n: metadata 69 | for n, metadata in self.parent.backend.all_nodes_as_iterable( 70 | include_metadata=True 71 | ) 72 | } 73 | 74 | def __str__(self): 75 | return "_GrandNodeAtlasView" 76 | 77 | def __repr__(self): 78 | return "_GrandNodeAtlasView" 79 | 80 | 81 | class NetworkXDialect(nx.Graph): 82 | """ 83 | A NetworkXDialect provides a networkx-like interface for graph manipulation 84 | 85 | """ 86 | 87 | def __init__(self, parent: "Graph"): 88 | """ 89 | Create a new dialect to query a backend with NetworkX syntax. 90 | 91 | Arguments: 92 | parent (Graph): The parent Graph object 93 | 94 | Returns: 95 | None 96 | 97 | """ 98 | self.parent = parent 99 | 100 | def add_node(self, name: Hashable, **kwargs): 101 | return self.parent.backend.add_node(name, kwargs) 102 | 103 | def add_nodes_from(self, nodes_for_adding, **attr): 104 | return self.parent.backend.add_nodes_from(nodes_for_adding, **attr) 105 | 106 | def add_edge(self, u: Hashable, v: Hashable, **kwargs): 107 | return self.parent.backend.add_edge(u, v, kwargs) 108 | 109 | def add_edges_from(self, ebunch_to_add, **attr): 110 | return self.parent.backend.add_edges_from(ebunch_to_add, **attr) 111 | 112 | def remove_node(self, name: Hashable): 113 | if hasattr(self.parent.backend, "remove_node"): 114 | return self.parent.backend.remove_node(name) 115 | raise NotImplementedError 116 | 117 | def remove_edge(self, u: Hashable, v: Hashable): 118 | raise NotImplementedError 119 | 120 | def neighbors(self, u: Hashable) -> Generator: 121 | return self.parent.backend.get_node_neighbors(u) 122 | 123 | def predecessors(self, u: Hashable) -> Generator: 124 | return self.parent.backend.get_node_predecessors(u) 125 | 126 | def successors(self, u: Hashable) -> Generator: 127 | return self.parent.backend.get_node_neighbors(u) 128 | 129 | @property 130 | def _node(self): 131 | return _GrandNodeAtlasView(self) 132 | 133 | @property 134 | def adj(self): 135 | """ 136 | https://github.com/networkx/networkx/blob/master/networkx/classes/digraph.py#L323 137 | """ 138 | return _GrandAdjacencyView(self, "succ") 139 | 140 | @property 141 | def _adj(self): 142 | """ 143 | https://github.com/networkx/networkx/blob/master/networkx/classes/digraph.py#L323 144 | """ 145 | return _GrandAdjacencyView(self, "succ") 146 | 147 | @property 148 | def succ(self): 149 | return _GrandAdjacencyView(self, "succ") 150 | 151 | @property 152 | def _succ(self): 153 | return _GrandAdjacencyView(self, "succ") 154 | 155 | @property 156 | def pred(self): 157 | """ 158 | https://github.com/networkx/networkx/blob/master/networkx/classes/digraph.py#L323 159 | """ 160 | return _GrandAdjacencyView(self, "pred") 161 | 162 | @property 163 | def _pred(self): 164 | """ 165 | https://github.com/networkx/networkx/blob/master/networkx/classes/digraph.py#L323 166 | """ 167 | return _GrandAdjacencyView(self, "pred") 168 | 169 | @property 170 | def graph(self): 171 | return {} 172 | 173 | def in_degree(self, nbunch=None): 174 | return self.parent.backend.in_degrees(nbunch) 175 | 176 | def out_degree(self, nbunch=None): 177 | return self.parent.backend.out_degrees(nbunch) 178 | 179 | def degree(self, nbunch=None): 180 | if self.parent.backend.is_directed(): 181 | # For directed graphs, degree = in_degree + out_degree 182 | if nbunch is None: 183 | # Return DegreeView-like object for all nodes 184 | from networkx.classes.reportviews import DegreeView 185 | 186 | combined_degrees = {} 187 | for node in self.parent.backend.all_nodes_as_iterable(): 188 | in_deg = self.parent.backend.in_degree(node) 189 | out_deg = self.parent.backend.out_degree(node) 190 | combined_degrees[node] = in_deg + out_deg 191 | return DegreeView(combined_degrees) 192 | elif hasattr(nbunch, "__iter__") and not isinstance(nbunch, str): 193 | # nbunch is a list/iterable of nodes 194 | from networkx.classes.reportviews import DegreeView 195 | 196 | result = {} 197 | for node in nbunch: 198 | in_deg = self.parent.backend.in_degree(node) 199 | out_deg = self.parent.backend.out_degree(node) 200 | result[node] = in_deg + out_deg 201 | return DegreeView(result) 202 | else: 203 | # nbunch is a single node 204 | in_deg = self.parent.backend.in_degree(nbunch) 205 | out_deg = self.parent.backend.out_degree(nbunch) 206 | return in_deg + out_deg 207 | else: 208 | # For undirected graphs, use the backend's degree method directly 209 | if nbunch is None: 210 | # Return DegreeView for all nodes 211 | from networkx.classes.reportviews import DegreeView 212 | 213 | degrees_dict = self.parent.backend.degrees(nbunch) 214 | return DegreeView(degrees_dict) 215 | elif hasattr(nbunch, "__iter__") and not isinstance(nbunch, str): 216 | # nbunch is a list/iterable of nodes 217 | from networkx.classes.reportviews import DegreeView 218 | 219 | degrees_dict = self.parent.backend.degrees(nbunch) 220 | return DegreeView(degrees_dict) 221 | else: 222 | # nbunch is a single node 223 | return self.parent.backend.degree(nbunch) 224 | 225 | def is_directed(self): 226 | return self.parent.backend.is_directed() 227 | 228 | def __len__(self): 229 | return self.parent.backend.get_node_count() 230 | 231 | def number_of_nodes(self): 232 | return self.parent.backend.get_node_count() 233 | 234 | def number_of_edges(self, u=None, v=None): 235 | if u is None and v is None: 236 | return self.parent.backend.get_edge_count() 237 | # Get the number of edges between u and v. because we don't support 238 | # multigraphs, this is 1 if there is an edge, 0 otherwise. 239 | return 1 if self.parent.backend.has_edge(u, v) else 0 240 | 241 | 242 | class IGraphDialect(nx.Graph): 243 | """ 244 | An IGraphDialect provides a python-igraph-like interface 245 | 246 | """ 247 | 248 | def __init__(self, parent: "Graph"): 249 | """ 250 | Create a new dialect to query a backend with Python-IGraph syntax. 251 | 252 | Arguments: 253 | parent (Graph): The parent Graph object 254 | 255 | Returns: 256 | None 257 | 258 | """ 259 | self.parent = parent 260 | 261 | def add_vertices(self, num_verts: int): 262 | old_max = len(self.vs) 263 | for new_v_index in range(num_verts): 264 | self.parent.backend.add_node(new_v_index + old_max, {}) 265 | 266 | @property 267 | def vs(self): 268 | return [ 269 | i for i in self.parent.backend.all_nodes_as_iterable(include_metadata=True) 270 | ] 271 | 272 | @property 273 | def es(self): 274 | return [ 275 | i for i in self.parent.backend.all_edges_as_iterable(include_metadata=True) 276 | ] 277 | 278 | def add_edges(self, edgelist: List[Tuple[Hashable, Hashable]]): 279 | for u, v in edgelist: 280 | self.parent.backend.add_edge(u, v, {}) 281 | 282 | def get_edgelist(self): 283 | return self.parent.backend.all_edges_as_iterable(include_metadata=False) 284 | 285 | 286 | class NetworkitDialect: 287 | """ 288 | A Networkit-like API for interacting with a Grand graph. 289 | 290 | For more details on the original API, see here: 291 | https://networkit.github.io/dev-docs/python_api/graph.html 292 | 293 | """ 294 | 295 | def __init__(self, parent: "Graph") -> None: 296 | self.parent = parent 297 | 298 | def addNode(self): 299 | new_id = self.parent.backend.get_node_count() 300 | self.parent.backend.add_node(new_id, {}) 301 | return new_id 302 | 303 | def addEdge(self, u: Hashable, v: Hashable) -> None: 304 | self.parent.backend.add_edge(u, v, {}) 305 | 306 | def nodes(self): 307 | return [i for i in self.iterNodes()] 308 | 309 | def iterNodes(self): 310 | return self.parent.backend.all_nodes_as_iterable() 311 | 312 | def edges(self): 313 | return [i for i in self.iterEdges()] 314 | 315 | def iterEdges(self): 316 | return self.parent.backend.all_edges_as_iterable() 317 | 318 | def hasEdge(self, u, v) -> bool: 319 | return self.parent.backend.get_edge_by_id(u, v) is not None 320 | 321 | def addNodes(self, numberOfNewNodes: int) -> int: 322 | for _ in range(numberOfNewNodes): 323 | r = self.addNode() 324 | return r 325 | 326 | def hasNode(self, u) -> bool: 327 | return self.parent.backend.has_node(u) 328 | 329 | def degree(self, v): 330 | return self.parent.backend.degree(v) 331 | 332 | def degreeIn(self, v): 333 | return self.parent.backend.in_degree(v) 334 | 335 | def degreeOut(self, v): 336 | return self.parent.backend.out_degree(v) 337 | 338 | def density(self): 339 | # TODO: implement backend#degree? 340 | E = self.parent.backend.get_edge_count() 341 | V = self.parent.backend.get_node_count() 342 | 343 | if self.parent.backend.is_directed(): 344 | return E / (V * (V - 1)) 345 | else: 346 | return 2 * E / (V * (V - 1)) 347 | 348 | def numberOfNodes(self) -> int: 349 | return self.parent.backend.get_node_count() 350 | 351 | def numberOfEdges(self) -> int: 352 | return self.parent.backend.get_edge_count() 353 | 354 | def removeEdge(self, u, v) -> None: 355 | raise NotImplementedError 356 | return self.parent.backend.remove_edge(u, v) 357 | 358 | def removeNode(self, u: Hashable) -> None: 359 | if hasattr(self.parent.backend, "remove_node"): 360 | return self.parent.backend.remove_node(u) 361 | raise NotImplementedError 362 | 363 | def append(self, G): 364 | raise NotImplementedError 365 | 366 | def copyNodes(self): 367 | raise NotImplementedError 368 | 369 | def BFSEdgesFrom(self, start: Union[int, List[int]]): 370 | raise NotImplementedError 371 | -------------------------------------------------------------------------------- /grand/dialects/test_dialect.py: -------------------------------------------------------------------------------- 1 | import io 2 | import unittest 3 | 4 | from .. import Graph 5 | from ..backends import NetworkXBackend 6 | from . import ( 7 | NetworkXDialect, 8 | IGraphDialect, 9 | NetworkitDialect, 10 | _GrandAdjacencyView, 11 | _GrandNodeAtlasView, 12 | ) 13 | import networkx as nx 14 | 15 | 16 | class TestIGraphDialect(unittest.TestCase): 17 | def test_igraph_verts(self): 18 | G = Graph() 19 | self.assertEqual(G.igraph.vs, []) 20 | G.nx.add_node("1") 21 | self.assertEqual(G.igraph.vs, [("1", {})]) 22 | 23 | def test_add_verts(self): 24 | G = Graph() 25 | G.igraph.add_vertices(1) 26 | self.assertEqual(G.igraph.vs, [(0, {})]) 27 | G.igraph.add_vertices(1) 28 | self.assertEqual(G.igraph.vs, [(0, {}), (1, {})]) 29 | G.igraph.add_vertices(10) 30 | self.assertEqual(len(G.igraph.vs), 12) 31 | 32 | def test_igraph_edges(self): 33 | G = Graph() 34 | G.igraph.add_vertices(2) 35 | G.igraph.add_edges([(0, 1)]) 36 | self.assertEqual(G.igraph.es, [(0, 1, {})]) 37 | 38 | 39 | class TestNetworkXHelpers(unittest.TestCase): 40 | def test_nx_adj_length(self): 41 | G = Graph() 42 | assert len(G.nx.adj) == 0 43 | G.nx.add_node("1") 44 | G.nx.add_edge("1", "2") 45 | assert len(G.nx.adj["1"]) == 1 46 | 47 | 48 | class TestNetworkXDialect(unittest.TestCase): 49 | def test_nx_pred(self): 50 | G = Graph(directed=True) 51 | G.nx.add_edge("1", "2") 52 | G.nx.add_edge("1", "3") 53 | H = nx.DiGraph() 54 | H.add_edge("1", "2") 55 | H.add_edge("1", "3") 56 | self.assertEqual(G.nx.pred, H.pred) 57 | 58 | def test_nx_directed(self): 59 | G = Graph(directed=True) 60 | self.assertTrue(G.nx.is_directed()) 61 | 62 | G = Graph(directed=False) 63 | self.assertFalse(G.nx.is_directed()) 64 | 65 | def test_in_degree(self): 66 | G = Graph(directed=True) 67 | G.nx.add_edge("1", "2") 68 | G.nx.add_edge("1", "3") 69 | H = nx.DiGraph() 70 | H.add_edge("1", "2") 71 | H.add_edge("1", "3") 72 | self.assertEqual(dict(G.nx.in_degree()), dict(H.in_degree())) 73 | 74 | def test_out_degree(self): 75 | G = Graph(directed=True) 76 | G.nx.add_edge("1", "2") 77 | G.nx.add_edge("1", "3") 78 | H = nx.DiGraph() 79 | H.add_edge("1", "2") 80 | H.add_edge("1", "3") 81 | self.assertEqual(dict(G.nx.out_degree()), dict(H.out_degree())) 82 | 83 | def test_nx_edges(self): 84 | G = Graph(directed=True).nx 85 | H = nx.DiGraph() 86 | G.add_edge("1", "2") 87 | G.add_edge("1", "3") 88 | H.add_edge("1", "2") 89 | H.add_edge("1", "3") 90 | self.assertEqual(dict(G.edges), dict(H.edges)) 91 | self.assertEqual(dict(G.edges()), dict(H.edges())) 92 | self.assertEqual(list(G.edges["1", "2"]), list(H.edges["1", "2"])) 93 | 94 | def test_degree_undirected(self): 95 | G1 = Graph(backend=NetworkXBackend(directed=False)) 96 | G2 = nx.Graph() 97 | 98 | G1.nx.add_node(0) 99 | G1.nx.add_node(1) 100 | G1.nx.add_edge(0, 1) 101 | 102 | G2.add_node(0) 103 | G2.add_node(1) 104 | G2.add_edge(0, 1) 105 | 106 | assert G1.nx.degree(0) == G2.degree(0), f"{G1.nx.degree(0)} != {G2.degree(0)}" 107 | assert G1.nx.degree(1) == G2.degree(1), f"{G1.nx.degree(1)} != {G2.degree(1)}" 108 | 109 | def test_degree_directed(self): 110 | G1 = Graph(backend=NetworkXBackend(directed=True)) 111 | G2 = nx.DiGraph() 112 | 113 | G1.nx.add_node(0) 114 | G1.nx.add_node(1) 115 | G1.nx.add_edge(0, 1) 116 | 117 | G2.add_node(0) 118 | G2.add_node(1) 119 | G2.add_edge(0, 1) 120 | 121 | assert G1.nx.degree(0) == G2.degree(0), f"{G1.nx.degree(0)} != {G2.degree(0)}" 122 | assert G1.nx.degree(1) == G2.degree(1), f"{G1.nx.degree(1)} != {G2.degree(1)}" 123 | 124 | def test_nx_export(self): 125 | gg = Graph() 126 | f = io.BytesIO() 127 | nx.write_graphml(gg.nx, f) 128 | 129 | 130 | class TestNetworkitDialect(unittest.TestCase): 131 | def test_add_verts(self): 132 | G = Graph() 133 | u = G.networkit.addNode() 134 | assert G.networkit.hasNode(u) 135 | assert not G.networkit.hasNode("X") 136 | 137 | def test_add_edges(self): 138 | G = Graph() 139 | u = G.networkit.addNode() 140 | v = G.networkit.addNode() 141 | assert len(G.networkit.edges()) == 0 142 | assert G.networkit.numberOfEdges() == 0 143 | G.networkit.addEdge(u, v) 144 | assert G.networkit.hasEdge(u, v) 145 | assert len(G.networkit.edges()) == 1 146 | assert G.networkit.numberOfEdges() == 1 147 | assert G.networkit.numberOfNodes() == 2 148 | 149 | def test_nodes(self): 150 | G = Graph() 151 | G.networkit.addNode() 152 | G.networkit.addNode() 153 | self.assertEqual(len(G.networkit.nodes()), 2) 154 | assert G.networkit.numberOfNodes() == 2 155 | 156 | def test_undirected_degree(self): 157 | G = Graph(directed=False) 158 | assert G.networkit.numberOfNodes() == 0 159 | assert G.networkit.numberOfEdges() == 0 160 | u = G.networkit.addNode() 161 | v = G.networkit.addNode() 162 | assert G.networkit.numberOfNodes() == 2 163 | assert G.networkit.numberOfEdges() == 0 164 | assert G.networkit.degree(u) == 0 165 | assert G.networkit.degree(v) == 0 166 | G.networkit.addEdge(u, v) 167 | assert G.networkit.degree(u) == 1 168 | assert G.networkit.degree(v) == 1 169 | assert G.networkit.numberOfEdges() == 1 170 | 171 | def test_directed_degree(self): 172 | G = Graph(directed=True) 173 | assert G.networkit.numberOfNodes() == 0 174 | assert G.networkit.numberOfEdges() == 0 175 | u = G.networkit.addNode() 176 | v = G.networkit.addNode() 177 | assert G.networkit.numberOfNodes() == 2 178 | assert G.networkit.numberOfEdges() == 0 179 | assert G.networkit.degreeIn(u) == 0 180 | assert G.networkit.degreeOut(u) == 0 181 | assert G.networkit.degreeIn(v) == 0 182 | assert G.networkit.degreeOut(v) == 0 183 | G.networkit.addEdge(u, v) 184 | assert G.networkit.degreeIn(u) == 0 185 | assert G.networkit.degreeOut(u) == 1 186 | assert G.networkit.degreeIn(v) == 1 187 | assert G.networkit.degreeOut(v) == 0 188 | # Test total degree for directed graphs 189 | assert G.networkit.degree(u) == 1 # in_degree + out_degree = 0 + 1 = 1 190 | assert G.networkit.degree(v) == 1 # in_degree + out_degree = 1 + 0 = 1 191 | assert G.networkit.numberOfEdges() == 1 192 | 193 | def test_undirected_density(self): 194 | G = Graph(directed=False) 195 | u = G.networkit.addNode() 196 | v = G.networkit.addNode() 197 | G.networkit.addEdge(u, v) 198 | assert G.networkit.density() == 1 199 | 200 | def test_directed_density(self): 201 | G = Graph(directed=True) 202 | u = G.networkit.addNode() 203 | v = G.networkit.addNode() 204 | G.networkit.addEdge(u, v) 205 | assert G.networkit.density() == 0.5 206 | G.networkit.addEdge(v, u) 207 | assert G.networkit.density() == 1 208 | -------------------------------------------------------------------------------- /grand/test_graph.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | 3 | from . import Graph, DiGraph 4 | 5 | 6 | class TestGraph(unittest.TestCase): 7 | def test_can_create(self): 8 | Graph() 9 | 10 | def test_can_use_nx_backend(self): 11 | Graph().nx 12 | 13 | def test_can_create_directed(self): 14 | assert Graph(directed=True).nx.is_directed() is True 15 | assert Graph(directed=False).nx.is_directed() is False 16 | assert DiGraph().nx.is_directed() is True 17 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [project] 2 | name = "grand-graph" 3 | version = "0.7.0" 4 | description = "Graph database wrapper for non-graph datastores" 5 | authors = [{ name = "Jordan Matelsky", email = "opensource@matelsky.com" }] 6 | requires-python = ">=3.10" 7 | dependencies = [ 8 | "cachetools>=5.5.2", 9 | "networkx>=2.4", 10 | "numpy>=1.26.4", 11 | "pandas>=2.2.3", 12 | ] 13 | 14 | [build-system] 15 | requires = ["hatchling"] 16 | build-backend = "hatchling.build" 17 | 18 | [tool.hatch.version] 19 | path = "grand/__init__.py" 20 | 21 | [tool.hatch.build.targets.sdist] 22 | include = ["/grand"] 23 | 24 | [tool.hatch.build.targets.wheel] 25 | packages = ["grand"] 26 | 27 | [tool.uv] 28 | dev-dependencies = [ 29 | "pytest>=8.3.5", 30 | "ruff>=0.11.5", 31 | "pytest-codspeed>=3.2.0", 32 | "pytest-cov>=6.1.1", 33 | ] 34 | 35 | [project.optional-dependencies] 36 | dynamodb = ["boto3"] 37 | igraph = ["igraph"] 38 | networkit = ["cmake", "cython", "networkit", "numpy<2.0.0"] 39 | sql = ["SQLAlchemy>=1.3"] 40 | --------------------------------------------------------------------------------